From aef98926684cc8cfdcdb4bf9ec2aa444b42a13bd Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 7 Sep 2023 09:55:39 -0400 Subject: [PATCH 1/5] skip failing test suite (#165938) --- .../examples/unified_field_list_examples/existing_fields.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts b/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts index 184b7b5d788a0..b150016cc63f0 100644 --- a/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts +++ b/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts @@ -51,7 +51,8 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { await PageObjects.header.waitUntilLoadingHasFinished(); } - describe('Fields existence info', () => { + // Failing: See https://github.com/elastic/kibana/issues/165938 + describe.skip('Fields existence info', () => { before(async () => { await esArchiver.load( 'test/api_integration/fixtures/es_archiver/index_patterns/constant_keyword' From f2929192bd2f5950fa49bf072ea8c21ef534c468 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 7 Sep 2023 09:56:05 -0400 Subject: [PATCH 2/5] skip failing test suite (#165927) --- .../examples/unified_field_list_examples/existing_fields.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts b/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts index b150016cc63f0..2b8effe5fcd58 100644 --- a/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts +++ b/x-pack/test_serverless/functional/test_suites/common/examples/unified_field_list_examples/existing_fields.ts @@ -52,6 +52,7 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { } // Failing: See https://github.com/elastic/kibana/issues/165938 + // Failing: See https://github.com/elastic/kibana/issues/165927 describe.skip('Fields existence info', () => { before(async () => { await esArchiver.load( From 03f0cdc327cd365a332f0289538a74181cadb24b Mon Sep 17 00:00:00 2001 From: Alexi Doak <109488926+doakalexi@users.noreply.github.com> Date: Thu, 7 Sep 2023 06:59:01 -0700 Subject: [PATCH 3/5] [ResponseOps][Alerting] Create xpack.actions.queued.max circuit breaker (#164632) Resolves https://github.com/elastic/kibana/issues/162264 ## Summary Adds a limit on the maximum number of actions that can be queued with a circuit breaker. The limit in serverless is set to 10,000, and 1,000,000 in the other environments. - If a rule execution exceeds the limit, the circuit breaker kicks in and stops triggering actions. - Alerting rule's status updated to warning when circuit breaker is hit Did not update the `enqueueExecution` bc it's going to be removed in https://github.com/elastic/kibana/pull/165120. ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### To Verify - Create a 2 rules that have actions - Set `xpack.actions.queued.max` in kibana.yml to a low number like 2 or 3 - Use the run soon button to queue up actions and hit the circuit breaker. - The actions will not be scheduled and the rule status will be set to warning --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- config/serverless.yml | 1 + docs/settings/alert-action-settings.asciidoc | 3 + packages/kbn-config-schema/index.ts | 16 + .../actions_client/actions_client.test.ts | 8 + .../server/actions_client/actions_client.ts | 9 +- .../actions/server/actions_config.mock.ts | 1 + .../actions/server/actions_config.test.ts | 17 + .../plugins/actions/server/actions_config.ts | 10 +- x-pack/plugins/actions/server/config.ts | 8 + .../server/create_execute_function.test.ts | 92 ++- .../actions/server/create_execute_function.ts | 52 +- .../create_unsecured_execute_function.test.ts | 73 +- .../create_unsecured_execute_function.ts | 42 +- .../has_reached_queued_action_limit.test.ts | 75 +++ .../lib/has_reached_queued_actions_limit.ts | 44 ++ x-pack/plugins/actions/server/plugin.ts | 13 +- .../unsecured_actions_client.ts | 12 +- .../routes/rule/response/constants/v1.ts | 1 + .../common/routes/rule/response/schemas/v1.ts | 2 + x-pack/plugins/alerting/common/rule.ts | 1 + .../server/application/rule/constants.ts | 1 + .../application/rule/schemas/rule_schemas.ts | 2 + .../alerting/server/constants/translations.ts | 4 + .../alerting/server/data/rule/constants.ts | 1 + .../alerting_event_logger.test.ts | 2 + .../server/lib/last_run_status.test.ts | 59 ++ .../alerting/server/lib/last_run_status.ts | 9 +- .../server/lib/rule_execution_status.test.ts | 26 + .../server/lib/rule_execution_status.ts | 15 +- .../server/lib/rule_run_metrics_store.test.ts | 20 + .../server/lib/rule_run_metrics_store.ts | 17 + .../alerting/server/raw_rule_schema.ts | 1 + .../task_runner/execution_handler.test.ts | 631 +++++++++++------- .../server/task_runner/execution_handler.ts | 64 +- .../alerting/server/task_runner/fixtures.ts | 3 + .../server/task_runner/task_runner.test.ts | 26 +- .../task_runner_alerts_client.test.ts | 2 +- .../task_runner/task_runner_cancel.test.ts | 7 +- .../services/connectors_email_service.test.ts | 108 ++- .../services/connectors_email_service.ts | 29 +- .../connectors_email_service_provider.test.ts | 3 +- .../connectors_email_service_provider.ts | 7 +- .../sections/rules_list/translations.ts | 8 + .../alerting_api_integration/common/config.ts | 1 + .../common/plugins/alerts/server/routes.ts | 1 + .../spaces_only/tests/actions/index.ts | 1 + .../max_queued_actions_circuit_breaker.ts | 102 +++ 47 files changed, 1357 insertions(+), 273 deletions(-) create mode 100644 x-pack/plugins/actions/server/lib/has_reached_queued_action_limit.test.ts create mode 100644 x-pack/plugins/actions/server/lib/has_reached_queued_actions_limit.ts create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/actions/max_queued_actions_circuit_breaker.ts diff --git a/config/serverless.yml b/config/serverless.yml index 6f5235ffdedd2..31791f5183d4a 100644 --- a/config/serverless.yml +++ b/config/serverless.yml @@ -119,6 +119,7 @@ xpack.alerting.rules.run.ruleTypeOverrides: xpack.alerting.rules.minimumScheduleInterval.enforce: true xpack.alerting.rules.maxScheduledPerMinute: 400 xpack.actions.run.maxAttempts: 10 +xpack.actions.queued.max: 10000 # Disables ESQL in advanced settings (hides it from the UI) uiSettings: diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index c863c5d5e837f..a009b8bdc7b41 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -227,6 +227,9 @@ xpack.actions.run: maxAttempts: 5 -- +`xpack.actions.queued.max` {ess-icon}:: +Specifies the maximum number of actions that can be queued. Default: 1000000 + [float] [[preconfigured-connector-settings]] === Preconfigured connector settings diff --git a/packages/kbn-config-schema/index.ts b/packages/kbn-config-schema/index.ts index 88666aac94c7e..57c61c125ec73 100644 --- a/packages/kbn-config-schema/index.ts +++ b/packages/kbn-config-schema/index.ts @@ -140,6 +140,22 @@ function recordOf( return new RecordOfType(keyType, valueType, options); } +function oneOf( + types: [ + Type, + Type, + Type, + Type, + Type, + Type, + Type, + Type, + Type, + Type, + Type + ], + options?: TypeOptions +): Type; function oneOf( types: [Type, Type, Type, Type, Type, Type, Type, Type, Type, Type], options?: TypeOptions diff --git a/x-pack/plugins/actions/server/actions_client/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client/actions_client.test.ts index fa7410bb71178..233261ea213f7 100644 --- a/x-pack/plugins/actions/server/actions_client/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client/actions_client.test.ts @@ -3019,6 +3019,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, { id: uuidv4(), @@ -3027,6 +3028,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '456def', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, ]); expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ @@ -3051,6 +3053,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, { id: uuidv4(), @@ -3059,6 +3062,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '456def', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, ]) ).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to execute all actions]`); @@ -3081,6 +3085,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, { id: uuidv4(), @@ -3089,6 +3094,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '456def', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, ]); @@ -3112,6 +3118,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, { id: uuidv4(), @@ -3120,6 +3127,7 @@ describe('bulkEnqueueExecution()', () => { executionId: '456def', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'my-action-type', }, ]; await expect(actionsClient.bulkEnqueueExecution(opts)).resolves.toMatchInlineSnapshot( diff --git a/x-pack/plugins/actions/server/actions_client/actions_client.ts b/x-pack/plugins/actions/server/actions_client/actions_client.ts index 392b5ec7354b6..73a87900db1a7 100644 --- a/x-pack/plugins/actions/server/actions_client/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client/actions_client.ts @@ -55,6 +55,7 @@ import { ExecutionEnqueuer, ExecuteOptions as EnqueueExecutionOptions, BulkExecutionEnqueuer, + ExecutionResponse, } from '../create_execute_function'; import { ActionsAuthorization } from '../authorization/actions_authorization'; import { @@ -114,7 +115,7 @@ export interface ConstructorOptions { inMemoryConnectors: InMemoryConnector[]; actionExecutor: ActionExecutorContract; ephemeralExecutionEnqueuer: ExecutionEnqueuer; - bulkExecutionEnqueuer: BulkExecutionEnqueuer; + bulkExecutionEnqueuer: BulkExecutionEnqueuer; request: KibanaRequest; authorization: ActionsAuthorization; auditLogger?: AuditLogger; @@ -139,7 +140,7 @@ export interface ActionsClientContext { request: KibanaRequest; authorization: ActionsAuthorization; ephemeralExecutionEnqueuer: ExecutionEnqueuer; - bulkExecutionEnqueuer: BulkExecutionEnqueuer; + bulkExecutionEnqueuer: BulkExecutionEnqueuer; auditLogger?: AuditLogger; usageCounter?: UsageCounter; connectorTokenClient: ConnectorTokenClientContract; @@ -766,7 +767,9 @@ export class ActionsClient { }); } - public async bulkEnqueueExecution(options: EnqueueExecutionOptions[]): Promise { + public async bulkEnqueueExecution( + options: EnqueueExecutionOptions[] + ): Promise { const sources: Array> = []; options.forEach((option) => { if (option.source) { diff --git a/x-pack/plugins/actions/server/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index 95c6ec1c0cc11..49f1a807bce9f 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -28,6 +28,7 @@ const createActionsConfigMock = () => { validateEmailAddresses: jest.fn().mockReturnValue(undefined), getMaxAttempts: jest.fn().mockReturnValue(3), enableFooterInEmail: jest.fn().mockReturnValue(true), + getMaxQueued: jest.fn().mockReturnValue(1000), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index e1b761ee30001..d19fcbc363b88 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -563,3 +563,20 @@ describe('getMaxAttempts()', () => { expect(maxAttempts).toEqual(3); }); }); + +describe('getMaxQueued()', () => { + test('returns the queued actions max defined in config', () => { + const acu = getActionsConfigurationUtilities({ + ...defaultActionsConfig, + queued: { max: 1 }, + }); + const max = acu.getMaxQueued(); + expect(max).toEqual(1); + }); + + test('returns the default queued actions max', () => { + const acu = getActionsConfigurationUtilities(defaultActionsConfig); + const max = acu.getMaxQueued(); + expect(max).toEqual(1000000); + }); +}); diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index 78e62684ad898..240a65228b4dc 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -11,7 +11,13 @@ import url from 'url'; import { curry } from 'lodash'; import { pipe } from 'fp-ts/lib/pipeable'; -import { ActionsConfig, AllowedHosts, EnabledActionTypes, CustomHostSettings } from './config'; +import { + ActionsConfig, + AllowedHosts, + EnabledActionTypes, + CustomHostSettings, + DEFAULT_QUEUED_MAX, +} from './config'; import { getCanonicalCustomHostUrl } from './lib/custom_host_settings'; import { ActionTypeDisabledError } from './lib'; import { ProxySettings, ResponseSettings, SSLSettings } from './types'; @@ -54,6 +60,7 @@ export interface ActionsConfigurationUtilities { options?: ValidateEmailAddressesOptions ): string | undefined; enableFooterInEmail: () => boolean; + getMaxQueued: () => number; } function allowListErrorMessage(field: AllowListingField, value: string) { @@ -217,5 +224,6 @@ export function getActionsConfigurationUtilities( ); }, enableFooterInEmail: () => config.enableFooterInEmail, + getMaxQueued: () => config.queued?.max || DEFAULT_QUEUED_MAX, }; } diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index da45fd40cf925..a0b6c23883993 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -19,6 +19,9 @@ export enum EnabledActionTypes { const MAX_MAX_ATTEMPTS = 10; const MIN_MAX_ATTEMPTS = 1; +const MIN_QUEUED_MAX = 1; +export const DEFAULT_QUEUED_MAX = 1000000; + const preconfiguredActionSchema = schema.object({ name: schema.string({ minLength: 1 }), actionTypeId: schema.string({ minLength: 1 }), @@ -130,6 +133,11 @@ export const configSchema = schema.object({ }) ), enableFooterInEmail: schema.boolean({ defaultValue: true }), + queued: schema.maybe( + schema.object({ + max: schema.maybe(schema.number({ min: MIN_QUEUED_MAX, defaultValue: DEFAULT_QUEUED_MAX })), + }) + ), }); export type ActionsConfig = TypeOf; diff --git a/x-pack/plugins/actions/server/create_execute_function.test.ts b/x-pack/plugins/actions/server/create_execute_function.test.ts index 72903ca433b4e..162297a9a55cf 100644 --- a/x-pack/plugins/actions/server/create_execute_function.test.ts +++ b/x-pack/plugins/actions/server/create_execute_function.test.ts @@ -15,12 +15,24 @@ import { asHttpRequestExecutionSource, asSavedObjectExecutionSource, } from './lib/action_execution_source'; +import { actionsConfigMock } from './actions_config.mock'; const mockTaskManager = taskManagerMock.createStart(); const savedObjectsClient = savedObjectsClientMock.create(); const request = {} as KibanaRequest; +const mockActionsConfig = actionsConfigMock.create(); -beforeEach(() => jest.resetAllMocks()); +beforeEach(() => { + jest.resetAllMocks(); + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 0, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValue(10); +}); describe('bulkExecute()', () => { test('schedules the action with all given parameters', async () => { @@ -30,6 +42,7 @@ describe('bulkExecute()', () => { actionTypeRegistry, isESOCanEncrypt: true, inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, }); savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ @@ -63,6 +76,7 @@ describe('bulkExecute()', () => { executionId: '123abc', apiKey: Buffer.from('123:abc').toString('base64'), source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', }, ]); expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); @@ -118,6 +132,7 @@ describe('bulkExecute()', () => { actionTypeRegistry, isESOCanEncrypt: true, inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, }); savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ @@ -153,6 +168,7 @@ describe('bulkExecute()', () => { consumer: 'test-consumer', apiKey: Buffer.from('123:abc').toString('base64'), source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', }, ]); expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); @@ -209,6 +225,7 @@ describe('bulkExecute()', () => { actionTypeRegistry, isESOCanEncrypt: true, inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, }); savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ @@ -248,6 +265,7 @@ describe('bulkExecute()', () => { typeId: 'some-typeId', }, ], + actionTypeId: 'mock-action', }, ]); expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( @@ -304,6 +322,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); const source = { type: 'alert', id: uuidv4() }; @@ -339,6 +358,7 @@ describe('bulkExecute()', () => { executionId: '123abc', apiKey: Buffer.from('123:abc').toString('base64'), source: asSavedObjectExecutionSource(source), + actionTypeId: 'mock-action', }, ]); expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); @@ -401,6 +421,7 @@ describe('bulkExecute()', () => { isSystemAction: true, }, ], + configurationUtilities: mockActionsConfig, }); const source = { type: 'alert', id: uuidv4() }; @@ -436,6 +457,7 @@ describe('bulkExecute()', () => { executionId: 'system-connector-.casesabc', apiKey: Buffer.from('system-connector-test.system-action:abc').toString('base64'), source: asSavedObjectExecutionSource(source), + actionTypeId: 'mock-action', }, ]); expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); @@ -498,6 +520,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); const source = { type: 'alert', id: uuidv4() }; @@ -541,6 +564,7 @@ describe('bulkExecute()', () => { typeId: 'some-typeId', }, ], + actionTypeId: 'mock-action', }, ]); expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); @@ -616,6 +640,7 @@ describe('bulkExecute()', () => { isSystemAction: true, }, ], + configurationUtilities: mockActionsConfig, }); const source = { type: 'alert', id: uuidv4() }; @@ -659,6 +684,7 @@ describe('bulkExecute()', () => { typeId: 'some-typeId', }, ], + actionTypeId: 'mock-action', }, ]); expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); @@ -723,6 +749,7 @@ describe('bulkExecute()', () => { isESOCanEncrypt: false, actionTypeRegistry: actionTypeRegistryMock.create(), inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, }); await expect( executeFn(savedObjectsClient, [ @@ -733,6 +760,7 @@ describe('bulkExecute()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', }, ]) ).rejects.toThrowErrorMatchingInlineSnapshot( @@ -746,6 +774,7 @@ describe('bulkExecute()', () => { isESOCanEncrypt: true, actionTypeRegistry: actionTypeRegistryMock.create(), inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, }); savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ @@ -770,6 +799,7 @@ describe('bulkExecute()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', }, ]) ).rejects.toThrowErrorMatchingInlineSnapshot( @@ -784,6 +814,7 @@ describe('bulkExecute()', () => { isESOCanEncrypt: true, actionTypeRegistry: mockedActionTypeRegistry, inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, }); mockedActionTypeRegistry.ensureActionTypeEnabled.mockImplementation(() => { throw new Error('Fail'); @@ -810,6 +841,7 @@ describe('bulkExecute()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', }, ]) ).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`); @@ -833,6 +865,7 @@ describe('bulkExecute()', () => { isSystemAction: false, }, ], + configurationUtilities: mockActionsConfig, }); mockedActionTypeRegistry.isActionExecutable.mockImplementation(() => true); savedObjectsClient.bulkGet.mockResolvedValueOnce({ @@ -868,6 +901,7 @@ describe('bulkExecute()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', }, ]); @@ -892,6 +926,7 @@ describe('bulkExecute()', () => { isSystemAction: true, }, ], + configurationUtilities: mockActionsConfig, }); mockedActionTypeRegistry.isActionExecutable.mockImplementation(() => true); savedObjectsClient.bulkGet.mockResolvedValueOnce({ @@ -927,9 +962,64 @@ describe('bulkExecute()', () => { executionId: '123abc', apiKey: null, source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', }, ]); expect(mockedActionTypeRegistry.ensureActionTypeEnabled).not.toHaveBeenCalled(); }); + + test('returns queuedActionsLimitError response when the max number of queued actions has been reached', async () => { + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 2, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValueOnce(2); + const executeFn = createBulkExecutionEnqueuerFunction({ + taskManager: mockTaskManager, + actionTypeRegistry: actionTypeRegistryMock.create(), + isESOCanEncrypt: true, + inMemoryConnectors: [], + configurationUtilities: mockActionsConfig, + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [], + }); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [], + }); + expect( + await executeFn(savedObjectsClient, [ + { + id: '123', + params: { baz: false }, + spaceId: 'default', + executionId: '123abc', + apiKey: null, + source: asHttpRequestExecutionSource(request), + actionTypeId: 'mock-action', + }, + ]) + ).toMatchInlineSnapshot(` + Object { + "errors": true, + "items": Array [ + Object { + "actionTypeId": "mock-action", + "id": "123", + "response": "queuedActionsLimitError", + }, + ], + } + `); + expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); + expect(mockTaskManager.bulkSchedule.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [], + ] + `); + }); }); diff --git a/x-pack/plugins/actions/server/create_execute_function.ts b/x-pack/plugins/actions/server/create_execute_function.ts index 3b4233ddf5710..04d029f83c577 100644 --- a/x-pack/plugins/actions/server/create_execute_function.ts +++ b/x-pack/plugins/actions/server/create_execute_function.ts @@ -16,12 +16,15 @@ import { import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from './constants/saved_objects'; import { ExecuteOptions as ActionExecutorOptions } from './lib/action_executor'; import { extractSavedObjectReferences, isSavedObjectExecutionSource } from './lib'; +import { ActionsConfigurationUtilities } from './actions_config'; +import { hasReachedTheQueuedActionsLimit } from './lib/has_reached_queued_actions_limit'; interface CreateExecuteFunctionOptions { taskManager: TaskManagerStartContract; isESOCanEncrypt: boolean; actionTypeRegistry: ActionTypeRegistryContract; inMemoryConnectors: InMemoryConnector[]; + configurationUtilities: ActionsConfigurationUtilities; } export interface ExecuteOptions @@ -30,6 +33,7 @@ export interface ExecuteOptions spaceId: string; apiKey: string | null; executionId: string; + actionTypeId: string; } interface ActionTaskParams @@ -54,12 +58,29 @@ export type BulkExecutionEnqueuer = ( actionsToExectute: ExecuteOptions[] ) => Promise; +export enum ExecutionResponseType { + SUCCESS = 'success', + QUEUED_ACTIONS_LIMIT_ERROR = 'queuedActionsLimitError', +} + +export interface ExecutionResponse { + errors: boolean; + items: ExecutionResponseItem[]; +} + +export interface ExecutionResponseItem { + id: string; + actionTypeId: string; + response: ExecutionResponseType; +} + export function createBulkExecutionEnqueuerFunction({ taskManager, actionTypeRegistry, isESOCanEncrypt, inMemoryConnectors, -}: CreateExecuteFunctionOptions): BulkExecutionEnqueuer { + configurationUtilities, +}: CreateExecuteFunctionOptions): BulkExecutionEnqueuer { return async function execute( unsecuredSavedObjectsClient: SavedObjectsClientContract, actionsToExecute: ExecuteOptions[] @@ -70,6 +91,19 @@ export function createBulkExecutionEnqueuerFunction({ ); } + const { hasReachedLimit, numberOverLimit } = await hasReachedTheQueuedActionsLimit( + taskManager, + configurationUtilities, + actionsToExecute.length + ); + let actionsOverLimit: ExecuteOptions[] = []; + if (hasReachedLimit) { + actionsOverLimit = actionsToExecute.splice( + actionsToExecute.length - numberOverLimit, + numberOverLimit + ); + } + const actionTypeIds: Record = {}; const spaceIds: Record = {}; const connectorIsInMemory: Record = {}; @@ -144,6 +178,22 @@ export function createBulkExecutionEnqueuerFunction({ }; }); await taskManager.bulkSchedule(taskInstances); + return { + errors: actionsOverLimit.length > 0, + items: actionsToExecute + .map((a) => ({ + id: a.id, + actionTypeId: a.actionTypeId, + response: ExecutionResponseType.SUCCESS, + })) + .concat( + actionsOverLimit.map((a) => ({ + id: a.id, + actionTypeId: a.actionTypeId, + response: ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR, + })) + ), + }; }; } diff --git a/x-pack/plugins/actions/server/create_unsecured_execute_function.test.ts b/x-pack/plugins/actions/server/create_unsecured_execute_function.test.ts index 2bbfab40b7318..bce0647389396 100644 --- a/x-pack/plugins/actions/server/create_unsecured_execute_function.test.ts +++ b/x-pack/plugins/actions/server/create_unsecured_execute_function.test.ts @@ -14,11 +14,23 @@ import { asNotificationExecutionSource, asSavedObjectExecutionSource, } from './lib/action_execution_source'; +import { actionsConfigMock } from './actions_config.mock'; const mockTaskManager = taskManagerMock.createStart(); const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); +const mockActionsConfig = actionsConfigMock.create(); -beforeEach(() => jest.resetAllMocks()); +beforeEach(() => { + jest.resetAllMocks(); + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 0, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValue(10); +}); describe('bulkExecute()', () => { test.each([ @@ -42,6 +54,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); internalSavedObjectsRepository.bulkCreate.mockResolvedValueOnce({ @@ -154,6 +167,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); internalSavedObjectsRepository.bulkCreate.mockResolvedValueOnce({ @@ -278,6 +292,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); internalSavedObjectsRepository.bulkCreate.mockResolvedValueOnce({ @@ -426,6 +441,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); await expect( executeFn(internalSavedObjectsRepository, [ @@ -468,6 +484,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); mockedConnectorTypeRegistry.ensureActionTypeEnabled.mockImplementation(() => { throw new Error('Fail'); @@ -521,6 +538,7 @@ describe('bulkExecute()', () => { secrets: {}, }, ], + configurationUtilities: mockActionsConfig, }); await expect( executeFn(internalSavedObjectsRepository, [ @@ -540,4 +558,57 @@ describe('bulkExecute()', () => { ); } ); + + test.each([ + [true, false], + [false, true], + ])( + 'returns queuedActionsLimitError response when the max number of queued actions has been reached: %s, isSystemAction: %s', + async (isPreconfigured, isSystemAction) => { + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 2, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValueOnce(2); + const executeFn = createBulkUnsecuredExecutionEnqueuerFunction({ + taskManager: mockTaskManager, + connectorTypeRegistry: actionTypeRegistryMock.create(), + inMemoryConnectors: [ + { + id: '123', + actionTypeId: '.email', + config: {}, + isPreconfigured, + isDeprecated: false, + isSystemAction, + name: 'x', + secrets: {}, + }, + ], + configurationUtilities: mockActionsConfig, + }); + + internalSavedObjectsRepository.bulkCreate.mockResolvedValueOnce({ + saved_objects: [], + }); + expect( + await executeFn(internalSavedObjectsRepository, [ + { + id: '123', + params: { baz: false }, + source: asNotificationExecutionSource({ connectorId: 'abc', requesterId: 'foo' }), + }, + ]) + ).toEqual({ errors: true, items: [{ id: '123', response: 'queuedActionsLimitError' }] }); + expect(mockTaskManager.bulkSchedule).toHaveBeenCalledTimes(1); + expect(mockTaskManager.bulkSchedule.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [], + ] + `); + } + ); }); diff --git a/x-pack/plugins/actions/server/create_unsecured_execute_function.ts b/x-pack/plugins/actions/server/create_unsecured_execute_function.ts index 585f442c68e2f..a64a2494e5077 100644 --- a/x-pack/plugins/actions/server/create_unsecured_execute_function.ts +++ b/x-pack/plugins/actions/server/create_unsecured_execute_function.ts @@ -14,6 +14,9 @@ import { import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from './constants/saved_objects'; import { ExecuteOptions as ActionExecutorOptions } from './lib/action_executor'; import { extractSavedObjectReferences, isSavedObjectExecutionSource } from './lib'; +import { ExecutionResponseItem, ExecutionResponseType } from './create_execute_function'; +import { ActionsConfigurationUtilities } from './actions_config'; +import { hasReachedTheQueuedActionsLimit } from './lib/has_reached_queued_actions_limit'; // This allowlist should only contain connector types that don't require API keys for // execution. @@ -22,6 +25,7 @@ interface CreateBulkUnsecuredExecuteFunctionOptions { taskManager: TaskManagerStartContract; connectorTypeRegistry: ConnectorTypeRegistryContract; inMemoryConnectors: InMemoryConnector[]; + configurationUtilities: ActionsConfigurationUtilities; } export interface ExecuteOptions @@ -29,6 +33,11 @@ export interface ExecuteOptions id: string; } +export interface ExecutionResponse { + errors: boolean; + items: ExecutionResponseItem[]; +} + interface ActionTaskParams extends Pick { apiKey: string | null; @@ -43,11 +52,25 @@ export function createBulkUnsecuredExecutionEnqueuerFunction({ taskManager, connectorTypeRegistry, inMemoryConnectors, -}: CreateBulkUnsecuredExecuteFunctionOptions): BulkUnsecuredExecutionEnqueuer { + configurationUtilities, +}: CreateBulkUnsecuredExecuteFunctionOptions): BulkUnsecuredExecutionEnqueuer { return async function execute( internalSavedObjectsRepository: ISavedObjectsRepository, actionsToExecute: ExecuteOptions[] ) { + const { hasReachedLimit, numberOverLimit } = await hasReachedTheQueuedActionsLimit( + taskManager, + configurationUtilities, + actionsToExecute.length + ); + let actionsOverLimit: ExecuteOptions[] = []; + if (hasReachedLimit) { + actionsOverLimit = actionsToExecute.splice( + actionsToExecute.length - numberOverLimit, + numberOverLimit + ); + } + const connectorTypeIds: Record = {}; const connectorIds = [...new Set(actionsToExecute.map((action) => action.id))]; @@ -131,6 +154,23 @@ export function createBulkUnsecuredExecutionEnqueuerFunction({ }; }); await taskManager.bulkSchedule(taskInstances); + + return { + errors: actionsOverLimit.length > 0, + items: actionsToExecute + .map((a) => ({ + id: a.id, + response: ExecutionResponseType.SUCCESS, + actionTypeId: connectorTypeIds[a.id], + })) + .concat( + actionsOverLimit.map((a) => ({ + id: a.id, + response: ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR, + actionTypeId: connectorTypeIds[a.id], + })) + ), + }; }; } diff --git a/x-pack/plugins/actions/server/lib/has_reached_queued_action_limit.test.ts b/x-pack/plugins/actions/server/lib/has_reached_queued_action_limit.test.ts new file mode 100644 index 0000000000000..67772d33f872e --- /dev/null +++ b/x-pack/plugins/actions/server/lib/has_reached_queued_action_limit.test.ts @@ -0,0 +1,75 @@ +/* + * 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 { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { actionsConfigMock } from '../actions_config.mock'; +import { hasReachedTheQueuedActionsLimit } from './has_reached_queued_actions_limit'; + +const mockTaskManager = taskManagerMock.createStart(); +const mockActionsConfig = actionsConfigMock.create(); + +beforeEach(() => { + jest.resetAllMocks(); + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 0, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValue(10); +}); + +describe('hasReachedTheQueuedActionsLimit()', () => { + test('returns true if the number of queued actions is greater than the config limit', async () => { + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 3, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValueOnce(2); + + expect(await hasReachedTheQueuedActionsLimit(mockTaskManager, mockActionsConfig, 1)).toEqual({ + hasReachedLimit: true, + numberOverLimit: 2, + }); + }); + + test('returns true if the number of queued actions is equal the config limit', async () => { + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 2, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValueOnce(3); + + expect(await hasReachedTheQueuedActionsLimit(mockTaskManager, mockActionsConfig, 1)).toEqual({ + hasReachedLimit: true, + numberOverLimit: 0, + }); + }); + + test('returns false if the number of queued actions is less than the config limit', async () => { + mockTaskManager.aggregate.mockResolvedValue({ + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 1, relation: 'eq' }, max_score: null, hits: [] }, + aggregations: {}, + }); + mockActionsConfig.getMaxQueued.mockReturnValueOnce(3); + + expect(await hasReachedTheQueuedActionsLimit(mockTaskManager, mockActionsConfig, 1)).toEqual({ + hasReachedLimit: false, + numberOverLimit: 0, + }); + }); +}); diff --git a/x-pack/plugins/actions/server/lib/has_reached_queued_actions_limit.ts b/x-pack/plugins/actions/server/lib/has_reached_queued_actions_limit.ts new file mode 100644 index 0000000000000..3c88b82712925 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/has_reached_queued_actions_limit.ts @@ -0,0 +1,44 @@ +/* + * 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 { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +export async function hasReachedTheQueuedActionsLimit( + taskManager: TaskManagerStartContract, + configurationUtilities: ActionsConfigurationUtilities, + numberOfActions: number +) { + const limit = configurationUtilities.getMaxQueued(); + const { + hits: { total }, + } = await taskManager.aggregate({ + query: { + bool: { + filter: { + bool: { + must: [ + { + term: { + 'task.scope': 'actions', + }, + }, + ], + }, + }, + }, + }, + aggs: {}, + }); + const tasks = typeof total === 'number' ? total : total?.value ?? 0; + const numberOfTasks = tasks + numberOfActions; + const hasReachedLimit = numberOfTasks >= limit; + return { + hasReachedLimit, + numberOverLimit: hasReachedLimit ? numberOfTasks - limit : 0, + }; +} diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 415a9e36a1c01..ef5de6194d475 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -301,7 +301,7 @@ export class ActionsPlugin implements Plugin( 'actions', - this.createRouteHandlerContext(core) + this.createRouteHandlerContext(core, actionsConfigUtils) ); if (usageCollection) { const eventLogIndex = this.eventLogService.getIndexPattern(); @@ -404,8 +404,11 @@ export class ActionsPlugin implements Plugin + core: CoreSetup, + actionsConfigUtils: ActionsConfigurationUtilities ): IContextProvider => { const { actionTypeRegistry, @@ -687,12 +694,14 @@ export class ActionsPlugin implements Plugin; + executionEnqueuer: BulkUnsecuredExecutionEnqueuer; } export interface IUnsecuredActionsClient { - bulkEnqueueExecution: (requesterId: string, actionsToExecute: ExecuteOptions[]) => Promise; + bulkEnqueueExecution: ( + requesterId: string, + actionsToExecute: ExecuteOptions[] + ) => Promise; } export class UnsecuredActionsClient { private readonly internalSavedObjectsRepository: ISavedObjectsRepository; - private readonly executionEnqueuer: BulkUnsecuredExecutionEnqueuer; + private readonly executionEnqueuer: BulkUnsecuredExecutionEnqueuer; constructor(params: UnsecuredActionsClientOpts) { this.executionEnqueuer = params.executionEnqueuer; @@ -43,7 +47,7 @@ export class UnsecuredActionsClient { public async bulkEnqueueExecution( requesterId: string, actionsToExecute: ExecuteOptions[] - ): Promise { + ): Promise { // Check that requesterId is allowed if (!ALLOWED_REQUESTER_IDS.includes(requesterId)) { throw new Error( diff --git a/x-pack/plugins/alerting/common/routes/rule/response/constants/v1.ts b/x-pack/plugins/alerting/common/routes/rule/response/constants/v1.ts index a241412310482..fbeb08ba6bc7f 100644 --- a/x-pack/plugins/alerting/common/routes/rule/response/constants/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/response/constants/v1.ts @@ -40,6 +40,7 @@ export const ruleExecutionStatusErrorReason = { export const ruleExecutionStatusWarningReason = { MAX_EXECUTABLE_ACTIONS: 'maxExecutableActions', MAX_ALERTS: 'maxAlerts', + MAX_QUEUED_ACTIONS: 'maxQueuedActions', } as const; export type RuleNotifyWhen = typeof ruleNotifyWhen[keyof typeof ruleNotifyWhen]; diff --git a/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts index 3aecec56c3a41..2fb82c3558cb7 100644 --- a/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts @@ -110,6 +110,7 @@ export const ruleExecutionStatusSchema = schema.object({ reason: schema.oneOf([ schema.literal(ruleExecutionStatusWarningReasonV1.MAX_EXECUTABLE_ACTIONS), schema.literal(ruleExecutionStatusWarningReasonV1.MAX_ALERTS), + schema.literal(ruleExecutionStatusWarningReasonV1.MAX_QUEUED_ACTIONS), ]), message: schema.string(), }) @@ -136,6 +137,7 @@ export const ruleLastRunSchema = schema.object({ schema.literal(ruleExecutionStatusErrorReasonV1.VALIDATE), schema.literal(ruleExecutionStatusWarningReasonV1.MAX_EXECUTABLE_ACTIONS), schema.literal(ruleExecutionStatusWarningReasonV1.MAX_ALERTS), + schema.literal(ruleExecutionStatusWarningReasonV1.MAX_QUEUED_ACTIONS), ]) ) ), diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index 57ef90ed99620..22692a091a38c 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -60,6 +60,7 @@ export enum RuleExecutionStatusErrorReasons { export enum RuleExecutionStatusWarningReasons { MAX_EXECUTABLE_ACTIONS = 'maxExecutableActions', MAX_ALERTS = 'maxAlerts', + MAX_QUEUED_ACTIONS = 'maxQueuedActions', } export type RuleAlertingOutcome = 'failure' | 'success' | 'unknown' | 'warning'; diff --git a/x-pack/plugins/alerting/server/application/rule/constants.ts b/x-pack/plugins/alerting/server/application/rule/constants.ts index 7b0aa82a90ca9..bc75d91375ecb 100644 --- a/x-pack/plugins/alerting/server/application/rule/constants.ts +++ b/x-pack/plugins/alerting/server/application/rule/constants.ts @@ -40,4 +40,5 @@ export const ruleExecutionStatusErrorReason = { export const ruleExecutionStatusWarningReason = { MAX_EXECUTABLE_ACTIONS: 'maxExecutableActions', MAX_ALERTS: 'maxAlerts', + MAX_QUEUED_ACTIONS: 'maxQueuedActions', } as const; diff --git a/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts b/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts index 07efe4793b562..ef8f1dc652bff 100644 --- a/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts +++ b/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts @@ -55,6 +55,7 @@ export const ruleExecutionStatusSchema = schema.object({ reason: schema.oneOf([ schema.literal(ruleExecutionStatusWarningReason.MAX_EXECUTABLE_ACTIONS), schema.literal(ruleExecutionStatusWarningReason.MAX_ALERTS), + schema.literal(ruleExecutionStatusWarningReason.MAX_QUEUED_ACTIONS), ]), message: schema.string(), }) @@ -81,6 +82,7 @@ export const ruleLastRunSchema = schema.object({ schema.literal(ruleExecutionStatusErrorReason.VALIDATE), schema.literal(ruleExecutionStatusWarningReason.MAX_EXECUTABLE_ACTIONS), schema.literal(ruleExecutionStatusWarningReason.MAX_ALERTS), + schema.literal(ruleExecutionStatusWarningReason.MAX_QUEUED_ACTIONS), ]) ) ), diff --git a/x-pack/plugins/alerting/server/constants/translations.ts b/x-pack/plugins/alerting/server/constants/translations.ts index 15442cf8efc57..69fc9a39333b2 100644 --- a/x-pack/plugins/alerting/server/constants/translations.ts +++ b/x-pack/plugins/alerting/server/constants/translations.ts @@ -21,6 +21,10 @@ export const translations = { defaultMessage: 'Rule reported more than the maximum number of alerts in a single run. Alerts may be missed and recovery notifications may be delayed', }), + maxQueuedActions: i18n.translate('xpack.alerting.taskRunner.warning.maxQueuedActions', { + defaultMessage: + 'The maximum number of queued actions was reached; excess actions were not triggered.', + }), }, }, }; diff --git a/x-pack/plugins/alerting/server/data/rule/constants.ts b/x-pack/plugins/alerting/server/data/rule/constants.ts index 63d238a81574e..267864bdfd9e8 100644 --- a/x-pack/plugins/alerting/server/data/rule/constants.ts +++ b/x-pack/plugins/alerting/server/data/rule/constants.ts @@ -40,4 +40,5 @@ export const ruleExecutionStatusErrorReasonAttributes = { export const ruleExecutionStatusWarningReasonAttributes = { MAX_EXECUTABLE_ACTIONS: 'maxExecutableActions', MAX_ALERTS: 'maxAlerts', + MAX_QUEUED_ACTIONS: 'maxQueuedActions', } as const; diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts index 007cd4481bd7e..c8c2e5f1943ec 100644 --- a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts @@ -728,6 +728,7 @@ describe('AlertingEventLogger', () => { totalSearchDurationMs: 10333, hasReachedAlertLimit: false, triggeredActionsStatus: ActionsCompletion.COMPLETE, + hasReachedQueuedActionsLimit: false, }, }); @@ -826,6 +827,7 @@ describe('AlertingEventLogger', () => { totalSearchDurationMs: 10333, hasReachedAlertLimit: false, triggeredActionsStatus: ActionsCompletion.COMPLETE, + hasReachedQueuedActionsLimit: false, }, timings: { [TaskRunnerTimerSpan.StartTaskRun]: 10, diff --git a/x-pack/plugins/alerting/server/lib/last_run_status.test.ts b/x-pack/plugins/alerting/server/lib/last_run_status.test.ts index 33af749fe1e08..c4b1ac0acc3a7 100644 --- a/x-pack/plugins/alerting/server/lib/last_run_status.test.ts +++ b/x-pack/plugins/alerting/server/lib/last_run_status.test.ts @@ -13,6 +13,7 @@ import { RuleResultServiceResults, RuleResultService } from '../monitoring/rule_ const getMetrics = ({ hasReachedAlertLimit = false, triggeredActionsStatus = ActionsCompletion.COMPLETE, + hasReachedQueuedActionsLimit = false, }): RuleRunMetrics => { return { triggeredActionsStatus, @@ -25,6 +26,7 @@ const getMetrics = ({ numberOfTriggeredActions: 5, totalSearchDurationMs: 2, hasReachedAlertLimit, + hasReachedQueuedActionsLimit, }; }; @@ -126,6 +128,31 @@ describe('lastRunFromState', () => { }); }); + it('returns warning if rules actions completition is partial and queued action circuit breaker opens', () => { + const result = lastRunFromState( + { + metrics: getMetrics({ + triggeredActionsStatus: ActionsCompletion.PARTIAL, + hasReachedQueuedActionsLimit: true, + }), + }, + getRuleResultService({}) + ); + + expect(result.lastRun.outcome).toEqual('warning'); + expect(result.lastRun.outcomeMsg).toEqual([ + 'The maximum number of queued actions was reached; excess actions were not triggered.', + ]); + expect(result.lastRun.warning).toEqual('maxQueuedActions'); + + expect(result.lastRun.alertsCount).toEqual({ + active: 10, + new: 12, + recovered: 11, + ignored: 0, + }); + }); + it('overwrites rule execution warning if rule has reached alert limit; outcome messages are merged', () => { const ruleExecutionOutcomeMessage = 'Rule execution reported a warning'; const frameworkOutcomeMessage = @@ -184,6 +211,38 @@ describe('lastRunFromState', () => { }); }); + it('overwrites rule execution warning if rule has reached queued action limit; outcome messages are merged', () => { + const ruleExecutionOutcomeMessage = 'Rule execution reported a warning'; + const frameworkOutcomeMessage = + 'The maximum number of queued actions was reached; excess actions were not triggered.'; + const result = lastRunFromState( + { + metrics: getMetrics({ + triggeredActionsStatus: ActionsCompletion.PARTIAL, + hasReachedQueuedActionsLimit: true, + }), + }, + getRuleResultService({ + warnings: ['MOCK_WARNING'], + outcomeMessage: 'Rule execution reported a warning', + }) + ); + + expect(result.lastRun.outcome).toEqual('warning'); + expect(result.lastRun.outcomeMsg).toEqual([ + frameworkOutcomeMessage, + ruleExecutionOutcomeMessage, + ]); + expect(result.lastRun.warning).toEqual('maxQueuedActions'); + + expect(result.lastRun.alertsCount).toEqual({ + active: 10, + new: 12, + recovered: 11, + ignored: 0, + }); + }); + it('overwrites warning outcome to error if rule execution reports an error', () => { const result = lastRunFromState( { diff --git a/x-pack/plugins/alerting/server/lib/last_run_status.ts b/x-pack/plugins/alerting/server/lib/last_run_status.ts index 56da93f074c27..dedee9a658360 100644 --- a/x-pack/plugins/alerting/server/lib/last_run_status.ts +++ b/x-pack/plugins/alerting/server/lib/last_run_status.ts @@ -48,8 +48,13 @@ export const lastRunFromState = ( outcomeMsg.push(translations.taskRunner.warning.maxAlerts); } else if (metrics.triggeredActionsStatus === ActionsCompletion.PARTIAL) { outcome = RuleLastRunOutcomeValues[1]; - warning = RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS; - outcomeMsg.push(translations.taskRunner.warning.maxExecutableActions); + if (metrics.hasReachedQueuedActionsLimit) { + warning = RuleExecutionStatusWarningReasons.MAX_QUEUED_ACTIONS; + outcomeMsg.push(translations.taskRunner.warning.maxQueuedActions); + } else { + warning = RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS; + outcomeMsg.push(translations.taskRunner.warning.maxExecutableActions); + } } // Overwrite outcome to be error if last run reported any errors diff --git a/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts b/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts index 0210de56a6b0d..34c831db01a75 100644 --- a/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts +++ b/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts @@ -30,6 +30,7 @@ const executionMetrics = { numberOfRecoveredAlerts: 13, hasReachedAlertLimit: false, triggeredActionsStatus: ActionsCompletion.COMPLETE, + hasReachedQueuedActionsLimit: false, }; describe('RuleExecutionStatus', () => { @@ -48,6 +49,7 @@ describe('RuleExecutionStatus', () => { expect(received.numberOfNewAlerts).toEqual(expected.numberOfNewAlerts); expect(received.hasReachedAlertLimit).toEqual(expected.hasReachedAlertLimit); expect(received.triggeredActionsStatus).toEqual(expected.triggeredActionsStatus); + expect(received.hasReachedQueuedActionsLimit).toEqual(expected.hasReachedQueuedActionsLimit); } describe('executionStatusFromState()', () => { @@ -107,6 +109,30 @@ describe('RuleExecutionStatus', () => { }); }); + test('task state with max queued actions warning', () => { + const { status, metrics } = executionStatusFromState({ + alertInstances: { a: {} }, + metrics: { + ...executionMetrics, + triggeredActionsStatus: ActionsCompletion.PARTIAL, + hasReachedQueuedActionsLimit: true, + }, + }); + checkDateIsNearNow(status.lastExecutionDate); + expect(status.warning).toEqual({ + message: translations.taskRunner.warning.maxQueuedActions, + reason: RuleExecutionStatusWarningReasons.MAX_QUEUED_ACTIONS, + }); + expect(status.status).toBe('warning'); + expect(status.error).toBe(undefined); + + testExpectedMetrics(metrics!, { + ...executionMetrics, + triggeredActionsStatus: ActionsCompletion.PARTIAL, + hasReachedQueuedActionsLimit: true, + }); + }); + test('task state with max alerts warning', () => { const { status, metrics } = executionStatusFromState({ alertInstances: { a: {} }, diff --git a/x-pack/plugins/alerting/server/lib/rule_execution_status.ts b/x-pack/plugins/alerting/server/lib/rule_execution_status.ts index 43ab9e2153a94..2fea90c2410ff 100644 --- a/x-pack/plugins/alerting/server/lib/rule_execution_status.ts +++ b/x-pack/plugins/alerting/server/lib/rule_execution_status.ts @@ -47,10 +47,17 @@ export function executionStatusFromState( }; } else if (stateWithMetrics.metrics.triggeredActionsStatus === ActionsCompletion.PARTIAL) { status = RuleExecutionStatusValues[5]; - warning = { - reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, - message: translations.taskRunner.warning.maxExecutableActions, - }; + if (stateWithMetrics.metrics.hasReachedQueuedActionsLimit) { + warning = { + reason: RuleExecutionStatusWarningReasons.MAX_QUEUED_ACTIONS, + message: translations.taskRunner.warning.maxQueuedActions, + }; + } else { + warning = { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: translations.taskRunner.warning.maxExecutableActions, + }; + } } return { diff --git a/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.test.ts b/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.test.ts index 8f2410480cc6f..e2b7cc61550bd 100644 --- a/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.test.ts +++ b/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.test.ts @@ -25,6 +25,7 @@ describe('RuleRunMetricsStore', () => { expect(ruleRunMetricsStore.getNumberOfNewAlerts()).toBe(0); expect(ruleRunMetricsStore.getStatusByConnectorType('any')).toBe(undefined); expect(ruleRunMetricsStore.getHasReachedAlertLimit()).toBe(false); + expect(ruleRunMetricsStore.getHasReachedQueuedActionsLimit()).toBe(false); }); test('sets and returns numSearches', () => { @@ -95,6 +96,11 @@ describe('RuleRunMetricsStore', () => { expect(metricsStore.getEsSearchDurationMs()).toEqual(555); }); + test('sets and returns hasReachedQueuedActionsLimit', () => { + ruleRunMetricsStore.setHasReachedQueuedActionsLimit(true); + expect(ruleRunMetricsStore.getHasReachedQueuedActionsLimit()).toBe(true); + }); + test('gets metrics', () => { expect(ruleRunMetricsStore.getMetrics()).toEqual({ triggeredActionsStatus: 'partial', @@ -107,6 +113,7 @@ describe('RuleRunMetricsStore', () => { numberOfTriggeredActions: 5, totalSearchDurationMs: 2, hasReachedAlertLimit: true, + hasReachedQueuedActionsLimit: true, }); }); @@ -150,6 +157,19 @@ describe('RuleRunMetricsStore', () => { ).toBe(1); }); + // decrement + test('decrements numberOfTriggeredActions by 1', () => { + ruleRunMetricsStore.decrementNumberOfTriggeredActions(); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(5); + }); + + test('decrements numberOfTriggeredActionsByConnectorType by 1', () => { + ruleRunMetricsStore.decrementNumberOfTriggeredActionsByConnectorType(testConnectorId); + expect( + ruleRunMetricsStore.getStatusByConnectorType(testConnectorId).numberOfTriggeredActions + ).toBe(0); + }); + // Checker test('checks if it has reached the executable actions limit', () => { expect(ruleRunMetricsStore.hasReachedTheExecutableActionsLimit({ default: { max: 10 } })).toBe( diff --git a/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.ts b/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.ts index 14879e1558ba6..80b72e0069bb6 100644 --- a/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.ts +++ b/x-pack/plugins/alerting/server/lib/rule_run_metrics_store.ts @@ -27,6 +27,7 @@ interface State { numberOfGeneratedActions: number; }; }; + hasReachedQueuedActionsLimit: boolean; } export type RuleRunMetrics = Omit & { @@ -44,6 +45,7 @@ export class RuleRunMetricsStore { numberOfNewAlerts: 0, hasReachedAlertLimit: false, connectorTypes: {}, + hasReachedQueuedActionsLimit: false, }; // Getters @@ -90,6 +92,9 @@ export class RuleRunMetricsStore { public getHasReachedAlertLimit = () => { return this.state.hasReachedAlertLimit; }; + public getHasReachedQueuedActionsLimit = () => { + return this.state.hasReachedQueuedActionsLimit; + }; // Setters public setSearchMetrics = (searchMetrics: SearchMetrics[]) => { @@ -135,6 +140,9 @@ export class RuleRunMetricsStore { public setHasReachedAlertLimit = (hasReachedAlertLimit: boolean) => { this.state.hasReachedAlertLimit = hasReachedAlertLimit; }; + public setHasReachedQueuedActionsLimit = (hasReachedQueuedActionsLimit: boolean) => { + this.state.hasReachedQueuedActionsLimit = hasReachedQueuedActionsLimit; + }; // Checkers public hasReachedTheExecutableActionsLimit = (actionsConfigMap: ActionsConfigMap): boolean => @@ -182,4 +190,13 @@ export class RuleRunMetricsStore { const currentVal = this.state.connectorTypes[actionTypeId]?.numberOfGeneratedActions || 0; set(this.state, `connectorTypes["${actionTypeId}"].numberOfGeneratedActions`, currentVal + 1); }; + + // Decrementer + public decrementNumberOfTriggeredActions = () => { + this.state.numberOfTriggeredActions--; + }; + public decrementNumberOfTriggeredActionsByConnectorType = (actionTypeId: string) => { + const currentVal = this.state.connectorTypes[actionTypeId]?.numberOfTriggeredActions || 0; + set(this.state, `connectorTypes["${actionTypeId}"].numberOfTriggeredActions`, currentVal - 1); + }; } diff --git a/x-pack/plugins/alerting/server/raw_rule_schema.ts b/x-pack/plugins/alerting/server/raw_rule_schema.ts index 5843467a4cb46..65d4b48ec7d66 100644 --- a/x-pack/plugins/alerting/server/raw_rule_schema.ts +++ b/x-pack/plugins/alerting/server/raw_rule_schema.ts @@ -10,6 +10,7 @@ import { schema } from '@kbn/config-schema'; const executionStatusWarningReason = schema.oneOf([ schema.literal('maxExecutableActions'), schema.literal('maxAlerts'), + schema.literal('maxQueuedActions'), ]); const executionStatusErrorReason = schema.oneOf([ diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts index ce86dd4756093..5125bc67b90ef 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts @@ -34,6 +34,7 @@ import sinon from 'sinon'; import { mockAAD } from './fixtures'; import { schema } from '@kbn/config-schema'; import { alertsClientMock } from '../alerts_client/alerts_client.mock'; +import { ExecutionResponseType } from '@kbn/actions-plugin/server/create_execute_function'; jest.mock('./inject_action_params', () => ({ injectActionParams: jest.fn(), @@ -137,6 +138,11 @@ const defaultExecutionParams = { alertsClient, }; +const defaultExecutionResponse = { + errors: false, + items: [{ actionTypeId: 'test', id: '1', response: ExecutionResponseType.SUCCESS }], +}; + let ruleRunMetricsStore: RuleRunMetricsStore; let clock: sinon.SinonFakeTimers; type ActiveActionGroup = 'default' | 'other-group'; @@ -223,6 +229,7 @@ describe('Execution Handler', () => { renderActionParameterTemplatesDefault ); ruleRunMetricsStore = new RuleRunMetricsStore(); + actionsClient.bulkEnqueueExecution.mockResolvedValue(defaultExecutionResponse); }); beforeAll(() => { clock = sinon.useFakeTimers(); @@ -238,39 +245,40 @@ describe('Execution Handler', () => { expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ + Array [ + Array [ + Object { + "actionTypeId": "test", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My goes here", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", - "params": Object { - "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", - "contextVal": "My goes here", - "foo": true, - "stateVal": "My goes here", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, ], - ] - `); + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(1); expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, { @@ -334,6 +342,7 @@ describe('Execution Handler', () => { expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith([ { + actionTypeId: 'test2', consumer: 'rule-consumer', id: '2', params: { @@ -423,39 +432,40 @@ describe('Execution Handler', () => { expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ + Array [ + Array [ + Object { + "actionTypeId": "test", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 2 goes here", + "contextVal": "My context-val goes here", + "foo": true, + "stateVal": "My goes here", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", - "params": Object { - "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 2 goes here", - "contextVal": "My context-val goes here", - "foo": true, - "stateVal": "My goes here", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, ], - ] - `); + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); }); test('state attribute gets parameterized', async () => { @@ -463,39 +473,40 @@ describe('Execution Handler', () => { await executionHandler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ + Array [ + Array [ + Object { + "actionTypeId": "test", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 2 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My state-val goes here", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", - "params": Object { - "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 2 goes here", - "contextVal": "My goes here", - "foo": true, - "stateVal": "My state-val goes here", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, ], - ] - `); + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); }); test(`logs an error when action group isn't part of actionGroups available for the ruleType`, async () => { @@ -514,6 +525,21 @@ describe('Execution Handler', () => { }); test('Stops triggering actions when the number of total triggered actions is reached the number of max executable actions', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + actionTypeId: 'test2', + id: '1', + response: ExecutionResponseType.SUCCESS, + }, + { + actionTypeId: 'test2', + id: '2', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); const actions = [ { id: '1', @@ -573,6 +599,27 @@ describe('Execution Handler', () => { }); test('Skips triggering actions for a specific action type when it reaches the limit for that specific action type', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { actionTypeId: 'test', id: '1', response: ExecutionResponseType.SUCCESS }, + { + actionTypeId: 'test-action-type-id', + id: '2', + response: ExecutionResponseType.SUCCESS, + }, + { + actionTypeId: 'another-action-type-id', + id: '4', + response: ExecutionResponseType.SUCCESS, + }, + { + actionTypeId: 'another-action-type-id', + id: '5', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); const actions = [ ...defaultExecutionParams.rule.actions, { @@ -652,6 +699,77 @@ describe('Execution Handler', () => { expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); }); + test('Stops triggering actions when the number of total queued actions is reached the number of max queued actions', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: true, + items: [ + { + actionTypeId: 'test', + id: '1', + response: ExecutionResponseType.SUCCESS, + }, + { + actionTypeId: 'test', + id: '2', + response: ExecutionResponseType.SUCCESS, + }, + { + actionTypeId: 'test', + id: '3', + response: ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR, + }, + ], + }); + const actions = [ + { + id: '1', + group: 'default', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + { + id: '2', + group: 'default', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + { + id: '3', + group: 'default', + actionTypeId: 'test', + params: { + foo: true, + contextVal: '{{context.value}} goes here', + stateVal: '{{state.value}} goes here', + }, + }, + ]; + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + ...defaultExecutionParams, + rule: { + ...defaultExecutionParams.rule, + actions, + }, + }) + ); + await executionHandler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(2); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(3); + expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.PARTIAL); + expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + }); + test('schedules alerts with recovered actions', async () => { const actions = [ { @@ -680,39 +798,40 @@ describe('Execution Handler', () => { expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ + Array [ + Array [ + Object { + "actionTypeId": "test", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My goes here", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", - "params": Object { - "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", - "contextVal": "My goes here", - "foo": true, - "stateVal": "My goes here", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, ], - ] - `); + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); }); test('does not schedule alerts with recovered actions that are muted', async () => { @@ -852,6 +971,16 @@ describe('Execution Handler', () => { }); test('triggers summary actions (per rule run)', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + actionTypeId: 'testActionTypeId', + id: '1', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); alertsClient.getSummarizedAlerts.mockResolvedValue({ new: { count: 1, @@ -895,36 +1024,37 @@ describe('Execution Handler', () => { }); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ + Array [ + Array [ + Object { + "actionTypeId": "testActionTypeId", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "message": "New: 1 Ongoing: 0 Recovered: 0", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", - "params": Object { - "message": "New: 1 Ongoing: 0 Recovered: 0", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, ], - ] - `); + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); expect(alertingEventLogger.logAction).toBeCalledWith({ alertSummary: { new: 1, ongoing: 0, recovered: 0 }, id: '1', @@ -970,6 +1100,16 @@ describe('Execution Handler', () => { }); test('triggers summary actions (custom interval)', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + actionTypeId: 'testActionTypeId', + id: '1', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); alertsClient.getSummarizedAlerts.mockResolvedValue({ new: { count: 1, @@ -1022,36 +1162,37 @@ describe('Execution Handler', () => { }); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ + Array [ + Array [ + Object { + "actionTypeId": "testActionTypeId", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "message": "New: 1 Ongoing: 0 Recovered: 0", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", - "params": Object { - "message": "New: 1 Ongoing: 0 Recovered: 0", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, ], - ] - `); + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); expect(alertingEventLogger.logAction).toBeCalledWith({ alertSummary: { new: 1, ongoing: 0, recovered: 0 }, id: '1', @@ -1206,6 +1347,17 @@ describe('Execution Handler', () => { }); test('schedules alerts with multiple recovered actions', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { actionTypeId: 'test', id: '1', response: ExecutionResponseType.SUCCESS }, + { + actionTypeId: 'test', + id: '2', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); const actions = [ { id: '1', @@ -1245,70 +1397,82 @@ describe('Execution Handler', () => { expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ + Array [ + Array [ + Object { + "actionTypeId": "test", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My goes here", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", "id": "1", - "params": Object { - "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", - "contextVal": "My goes here", - "foo": true, - "stateVal": "My goes here", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, + ], + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + Object { + "actionTypeId": "test", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "2", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My goes here", + }, + "relatedSavedObjects": Array [ Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", - "id": "2", - "params": Object { - "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", - "contextVal": "My goes here", - "foo": true, - "stateVal": "My goes here", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", }, ], - ] - `); + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); }); test('does not schedule actions for the summarized alerts that are filtered out (for each alert)', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + actionTypeId: 'testActionTypeId', + id: '1', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); alertsClient.getSummarizedAlerts.mockResolvedValue({ new: { count: 0, @@ -1372,6 +1536,16 @@ describe('Execution Handler', () => { }); test('does not schedule actions for the summarized alerts that are filtered out (summary of alerts onThrottleInterval)', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + actionTypeId: 'testActionTypeId', + id: '1', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); alertsClient.getSummarizedAlerts.mockResolvedValue({ new: { count: 0, @@ -1432,6 +1606,16 @@ describe('Execution Handler', () => { }); test('does not schedule actions for the for-each type alerts that are filtered out', async () => { + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + actionTypeId: 'testActionTypeId', + id: '1', + response: ExecutionResponseType.SUCCESS, + }, + ], + }); alertsClient.getSummarizedAlerts.mockResolvedValue({ new: { count: 1, @@ -1486,6 +1670,7 @@ describe('Execution Handler', () => { }); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith([ { + actionTypeId: 'testActionTypeId', apiKey: 'MTIzOmFiYw==', consumer: 'rule-consumer', executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts index 6214482ec2706..c3002b7efe67a 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts @@ -10,7 +10,11 @@ import { Logger } from '@kbn/core/server'; import { getRuleDetailsRoute, triggersActionsRoute } from '@kbn/rule-data-utils'; import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; import { isEphemeralTaskRejectedDueToCapacityError } from '@kbn/task-manager-plugin/server'; -import { ExecuteOptions as EnqueueExecutionOptions } from '@kbn/actions-plugin/server/create_execute_function'; +import { + ExecuteOptions as EnqueueExecutionOptions, + ExecutionResponseItem, + ExecutionResponseType, +} from '@kbn/actions-plugin/server/create_execute_function'; import { ActionsCompletion } from '@kbn/alerting-state-types'; import { ActionsClient } from '@kbn/actions-plugin/server/actions_client'; import { chunk } from 'lodash'; @@ -49,6 +53,18 @@ enum Reasons { ACTION_GROUP_NOT_CHANGED = 'actionGroupHasNotChanged', } +interface LogAction { + id: string; + typeId: string; + alertId?: string; + alertGroup?: string; + alertSummary?: { + new: number; + ongoing: number; + recovered: number; + }; +} + export interface RunResult { throttledSummaryActions: ThrottledActions; } @@ -176,8 +192,9 @@ export class ExecutionHandler< }, } = this; - const logActions = []; + const logActions: Record = {}; const bulkActions: EnqueueExecutionOptions[] = []; + let bulkActionsResponse: ExecutionResponseItem[] = []; this.ruleRunMetricsStore.incrementNumberOfGeneratedActions(executables.length); @@ -262,7 +279,7 @@ export class ExecutionHandler< throttledSummaryActions[action.uuid!] = { date: new Date().toISOString() }; } - logActions.push({ + logActions[action.id] = { id: action.id, typeId: action.actionTypeId, alertSummary: { @@ -270,7 +287,7 @@ export class ExecutionHandler< ongoing: summarizedAlerts.ongoing.count, recovered: summarizedAlerts.recovered.count, }, - }); + }; } else { const ruleUrl = this.buildRuleUrl(spaceId); const actionToRun = { @@ -307,12 +324,12 @@ export class ExecutionHandler< bulkActions, }); - logActions.push({ + logActions[action.id] = { id: action.id, typeId: action.actionTypeId, alertId: alert.getId(), alertGroup: action.group, - }); + }; if (!this.isRecoveredAlert(actionGroup)) { if (isActionOnInterval(action)) { @@ -331,12 +348,40 @@ export class ExecutionHandler< if (!!bulkActions.length) { for (const c of chunk(bulkActions, CHUNK_SIZE)) { - await this.actionsClient!.bulkEnqueueExecution(c); + const response = await this.actionsClient!.bulkEnqueueExecution(c); + if (response.errors) { + bulkActionsResponse = bulkActionsResponse.concat( + response.items.filter( + (i) => i.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR + ) + ); + } + } + } + + if (!!bulkActionsResponse.length) { + for (const r of bulkActionsResponse) { + if (r.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR) { + ruleRunMetricsStore.setHasReachedQueuedActionsLimit(true); + ruleRunMetricsStore.decrementNumberOfTriggeredActions(); + ruleRunMetricsStore.decrementNumberOfTriggeredActionsByConnectorType(r.actionTypeId); + ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId: r.actionTypeId, + status: ActionsCompletion.PARTIAL, + }); + + logger.debug( + `Rule "${this.rule.id}" skipped scheduling action "${r.id}" because the maximum number of queued actions has been reached.` + ); + + delete logActions[r.id]; + } } } - if (!!logActions.length) { - for (const action of logActions) { + const logActionsValues = Object.values(logActions); + if (!!logActionsValues.length) { + for (const action of logActionsValues) { alertingEventLogger.logAction(action); } } @@ -509,6 +554,7 @@ export class ExecutionHandler< typeId: this.ruleType.id, }, ], + actionTypeId: action.actionTypeId, }; } diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index 467d7460afc2b..64c798b868db1 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -395,13 +395,16 @@ export const generateEnqueueFunctionInput = ({ isBulk = false, isResolved, foo, + actionTypeId, }: { id: string; isBulk?: boolean; isResolved?: boolean; foo?: boolean; + actionTypeId?: string; }) => { const input = { + actionTypeId: actionTypeId || 'action', apiKey: 'MTIzOmFiYw==', executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', id, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index fcd2464058350..c0d8a1434aa3d 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -237,6 +237,8 @@ describe('Task Runner', () => { logger.get.mockImplementation(() => logger); ruleType.executor.mockResolvedValue({ state: {} }); + + actionsClient.bulkEnqueueExecution.mockResolvedValue({ errors: false, items: [] }); }); test('successfully executes the task', async () => { @@ -299,7 +301,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 4, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); testAlertingEventLogCalls({ status: 'ok' }); @@ -381,7 +383,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 5, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); testAlertingEventLogCalls({ @@ -469,7 +471,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 6, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); testAlertingEventLogCalls({ @@ -723,7 +725,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 6, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":2,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":2,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":2,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":2,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } @@ -1168,7 +1170,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 6, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); testAlertingEventLogCalls({ @@ -1295,7 +1297,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 6, - `ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}` + `ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}` ); testAlertingEventLogCalls({ @@ -1490,7 +1492,7 @@ describe('Task Runner', () => { expect(enqueueFunction).toHaveBeenCalledTimes(1); expect(enqueueFunction).toHaveBeenCalledWith( - generateEnqueueFunctionInput({ isBulk, id: '1', foo: true }) + generateEnqueueFunctionInput({ isBulk, id: '1', foo: true, actionTypeId: 'slack' }) ); } ); @@ -1562,7 +1564,7 @@ describe('Task Runner', () => { expect(enqueueFunction).toHaveBeenCalledTimes(1); expect(enqueueFunction).toHaveBeenCalledWith( - generateEnqueueFunctionInput({ isBulk, id: '1', foo: true }) + generateEnqueueFunctionInput({ isBulk, id: '1', foo: true, actionTypeId: 'slack' }) ); expect(result.state.summaryActions).toEqual({ '111-111': { date: new Date(DATE_1970).toISOString() }, @@ -2440,7 +2442,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 4, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); testAlertingEventLogCalls({ @@ -2962,7 +2964,7 @@ describe('Task Runner', () => { status: 'warning', errorReason: `maxExecutableActions`, logAlert: 4, - logAction: 5, + logAction: 3, }); }); @@ -3146,6 +3148,7 @@ describe('Task Runner', () => { logAlert = 0, logAction = 0, hasReachedAlertLimit = false, + hasReachedQueuedActionsLimit = false, }: { status: string; ruleContext?: RuleContextOpts; @@ -3162,6 +3165,7 @@ describe('Task Runner', () => { errorReason?: string; errorMessage?: string; hasReachedAlertLimit?: boolean; + hasReachedQueuedActionsLimit?: boolean; }) { expect(alertingEventLogger.initialize).toHaveBeenCalledWith(ruleContext); if (status !== 'skip') { @@ -3215,6 +3219,7 @@ describe('Task Runner', () => { totalSearchDurationMs: 23423, hasReachedAlertLimit, triggeredActionsStatus: 'partial', + hasReachedQueuedActionsLimit, }, status: { lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), @@ -3250,6 +3255,7 @@ describe('Task Runner', () => { totalSearchDurationMs: 23423, hasReachedAlertLimit, triggeredActionsStatus: 'complete', + hasReachedQueuedActionsLimit, }, status: { lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), 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 3ed2a63feacdc..98e0643abfd50 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 @@ -409,7 +409,7 @@ describe('Task Runner', () => { ); expect(logger.debug).nthCalledWith( 5, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index 870cad51ed4b7..46783154a6a4a 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -191,6 +191,8 @@ describe('Task Runner Cancel', () => { alertingEventLogger.getStartAndDuration.mockImplementation(() => ({ start: new Date() })); (AlertingEventLogger as jest.Mock).mockImplementation(() => alertingEventLogger); logger.get.mockImplementation(() => logger); + + actionsClient.bulkEnqueueExecution.mockResolvedValue({ errors: false, items: [] }); }); test('updates rule saved object execution status and writes to event log entry when task is cancelled mid-execution', async () => { @@ -470,7 +472,7 @@ describe('Task Runner Cancel', () => { ); expect(logger.debug).nthCalledWith( 8, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' + 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); } @@ -485,6 +487,7 @@ describe('Task Runner Cancel', () => { logAlert = 0, logAction = 0, hasReachedAlertLimit = false, + hasReachedQueuedActionsLimit = false, }: { status: string; ruleContext?: RuleContextOpts; @@ -497,6 +500,7 @@ describe('Task Runner Cancel', () => { logAlert?: number; logAction?: number; hasReachedAlertLimit?: boolean; + hasReachedQueuedActionsLimit?: boolean; }) { expect(alertingEventLogger.initialize).toHaveBeenCalledWith(ruleContext); expect(alertingEventLogger.start).toHaveBeenCalled(); @@ -515,6 +519,7 @@ describe('Task Runner Cancel', () => { totalSearchDurationMs: 23423, hasReachedAlertLimit, triggeredActionsStatus: 'complete', + hasReachedQueuedActionsLimit, }, status: { lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), diff --git a/x-pack/plugins/notifications/server/services/connectors_email_service.test.ts b/x-pack/plugins/notifications/server/services/connectors_email_service.test.ts index 4e355e01cfc32..2f0a505ba1f55 100644 --- a/x-pack/plugins/notifications/server/services/connectors_email_service.test.ts +++ b/x-pack/plugins/notifications/server/services/connectors_email_service.test.ts @@ -5,18 +5,35 @@ * 2.0. */ +import { loggerMock } from '@kbn/logging-mocks'; import { unsecuredActionsClientMock } from '@kbn/actions-plugin/server/unsecured_actions_client/unsecured_actions_client.mock'; import { ConnectorsEmailService } from './connectors_email_service'; import type { PlainTextEmail, HTMLEmail } from './types'; +import { ExecutionResponseType } from '@kbn/actions-plugin/server/create_execute_function'; const REQUESTER_ID = 'requesterId'; const CONNECTOR_ID = 'connectorId'; describe('sendPlainTextEmail()', () => { + const logger = loggerMock.create(); + beforeEach(() => { + loggerMock.clear(logger); + }); + describe('calls the provided ActionsClient#bulkEnqueueExecution() with the appropriate params', () => { it(`omits the 'relatedSavedObjects' field if no context is provided`, () => { const actionsClient = unsecuredActionsClientMock.create(); - const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient); + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + id: CONNECTOR_ID, + response: ExecutionResponseType.SUCCESS, + actionTypeId: 'test', + }, + ], + }); + const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient, logger); const payload: PlainTextEmail = { to: ['user1@email.com'], subject: 'This is a notification email', @@ -40,7 +57,17 @@ describe('sendPlainTextEmail()', () => { it(`populates the 'relatedSavedObjects' field if context is provided`, () => { const actionsClient = unsecuredActionsClientMock.create(); - const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient); + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + id: CONNECTOR_ID, + response: ExecutionResponseType.SUCCESS, + actionTypeId: 'test', + }, + ], + }); + const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient, logger); const payload: PlainTextEmail = { to: ['user1@email.com', 'user2@email.com', 'user3@email.com'], subject: 'This is a notification email', @@ -107,14 +134,53 @@ describe('sendPlainTextEmail()', () => { }, ]); }); + + it(`logs an error when the maximum number of queued actions has been reached`, async () => { + const actionsClient = unsecuredActionsClientMock.create(); + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: true, + items: [ + { + id: CONNECTOR_ID, + response: ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR, + actionTypeId: 'test', + }, + ], + }); + const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient, logger); + const payload: PlainTextEmail = { + to: ['user1@email.com'], + subject: 'This is a notification email', + message: 'With some contents inside.', + }; + + await email.sendPlainTextEmail(payload); + + expect(logger.warn).toHaveBeenCalled(); + }); }); }); describe('sendHTMLEmail()', () => { + const logger = loggerMock.create(); + beforeEach(() => { + loggerMock.clear(logger); + }); + describe('calls the provided ActionsClient#bulkEnqueueExecution() with the appropriate params', () => { it(`omits the 'relatedSavedObjects' field if no context is provided`, () => { const actionsClient = unsecuredActionsClientMock.create(); - const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient); + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + id: CONNECTOR_ID, + response: ExecutionResponseType.SUCCESS, + actionTypeId: 'test', + }, + ], + }); + const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient, logger); const payload: HTMLEmail = { to: ['user1@email.com'], subject: 'This is a notification email', @@ -140,7 +206,17 @@ describe('sendHTMLEmail()', () => { it(`populates the 'relatedSavedObjects' field if context is provided`, () => { const actionsClient = unsecuredActionsClientMock.create(); - const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient); + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: false, + items: [ + { + id: CONNECTOR_ID, + response: ExecutionResponseType.SUCCESS, + actionTypeId: 'test', + }, + ], + }); + const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient, logger); const payload: HTMLEmail = { to: ['user1@email.com', 'user2@email.com', 'user3@email.com'], subject: 'This is a notification email', @@ -211,5 +287,29 @@ describe('sendHTMLEmail()', () => { }, ]); }); + it(`logs an error when the maximum number of queued actions has been reached`, async () => { + const actionsClient = unsecuredActionsClientMock.create(); + actionsClient.bulkEnqueueExecution.mockResolvedValueOnce({ + errors: true, + items: [ + { + id: CONNECTOR_ID, + response: ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR, + actionTypeId: 'test', + }, + ], + }); + const email = new ConnectorsEmailService(REQUESTER_ID, CONNECTOR_ID, actionsClient, logger); + const payload: HTMLEmail = { + to: ['user1@email.com'], + subject: 'This is a notification email', + message: 'With some contents inside.', + messageHTML: 'With some contents inside.', + }; + + await email.sendHTMLEmail(payload); + + expect(logger.warn).toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/notifications/server/services/connectors_email_service.ts b/x-pack/plugins/notifications/server/services/connectors_email_service.ts index 91958ce12d133..94acd532cdac5 100755 --- a/x-pack/plugins/notifications/server/services/connectors_email_service.ts +++ b/x-pack/plugins/notifications/server/services/connectors_email_service.ts @@ -6,13 +6,19 @@ */ import type { IUnsecuredActionsClient } from '@kbn/actions-plugin/server'; +import { + ExecutionResponseItem, + ExecutionResponseType, +} from '@kbn/actions-plugin/server/create_execute_function'; +import type { Logger } from '@kbn/core/server'; import type { EmailService, PlainTextEmail, HTMLEmail } from './types'; export class ConnectorsEmailService implements EmailService { constructor( private requesterId: string, private connectorId: string, - private actionsClient: IUnsecuredActionsClient + private actionsClient: IUnsecuredActionsClient, + private logger: Logger ) {} async sendPlainTextEmail(params: PlainTextEmail): Promise { @@ -25,7 +31,11 @@ export class ConnectorsEmailService implements EmailService { }, relatedSavedObjects: params.context?.relatedObjects, })); - return await this.actionsClient.bulkEnqueueExecution(this.requesterId, actions); + + const response = await this.actionsClient.bulkEnqueueExecution(this.requesterId, actions); + if (response.errors) { + this.logEnqueueExecutionResponse(response.items); + } } async sendHTMLEmail(params: HTMLEmail): Promise { @@ -40,6 +50,19 @@ export class ConnectorsEmailService implements EmailService { relatedSavedObjects: params.context?.relatedObjects, })); - return await this.actionsClient.bulkEnqueueExecution(this.requesterId, actions); + const response = await this.actionsClient.bulkEnqueueExecution(this.requesterId, actions); + if (response.errors) { + this.logEnqueueExecutionResponse(response.items); + } + } + + private logEnqueueExecutionResponse(items: ExecutionResponseItem[]) { + for (const r of items) { + if (r.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR) { + this.logger.warn( + `Skipped scheduling action "${r.id}" because the maximum number of queued actions has been reached.` + ); + } + } } } diff --git a/x-pack/plugins/notifications/server/services/connectors_email_service_provider.test.ts b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.test.ts index 7db7502640054..7c7cfacef5c35 100644 --- a/x-pack/plugins/notifications/server/services/connectors_email_service_provider.test.ts +++ b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.test.ts @@ -235,7 +235,8 @@ describe('ConnectorsEmailServiceProvider', () => { expect(connectorsEmailServiceMock).toHaveBeenCalledWith( PLUGIN_ID, validConnectorConfig.connectors.default.email, - actionsStart.getUnsecuredActionsClient() + actionsStart.getUnsecuredActionsClient(), + logger ); }); }); diff --git a/x-pack/plugins/notifications/server/services/connectors_email_service_provider.ts b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.ts index b3364f31d3689..5c631005c969e 100755 --- a/x-pack/plugins/notifications/server/services/connectors_email_service_provider.ts +++ b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.ts @@ -71,7 +71,12 @@ export class EmailServiceProvider try { const unsecuredActionsClient = actions.getUnsecuredActionsClient(); email = new LicensedEmailService( - new ConnectorsEmailService(PLUGIN_ID, emailConnector, unsecuredActionsClient), + new ConnectorsEmailService( + PLUGIN_ID, + emailConnector, + unsecuredActionsClient, + this.logger + ), licensing.license$, MINIMUM_LICENSE, this.logger diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/translations.ts index dd7a2b8cec3a2..f678bdbad9462 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/translations.ts @@ -154,6 +154,13 @@ export const ALERT_WARNING_MAX_EXECUTABLE_ACTIONS_REASON = i18n.translate( } ); +export const ALERT_WARNING_MAX_QUEUED_ACTIONS_REASON = i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.ruleWarningReasonMaxQueuedActions', + { + defaultMessage: 'Queued action limit exceeded.', + } +); + export const ALERT_WARNING_MAX_ALERTS_REASON = i18n.translate( 'xpack.triggersActionsUI.sections.rulesList.ruleWarningReasonMaxAlerts', { @@ -182,6 +189,7 @@ export const rulesErrorReasonTranslationsMapping = { export const rulesWarningReasonTranslationsMapping = { maxExecutableActions: ALERT_WARNING_MAX_EXECUTABLE_ACTIONS_REASON, maxAlerts: ALERT_WARNING_MAX_ALERTS_REASON, + maxQueuedActions: ALERT_WARNING_MAX_QUEUED_ACTIONS_REASON, unknown: ALERT_WARNING_UNKNOWN_REASON, }; diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 7509a8842af52..823ef92ea32ca 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -343,6 +343,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) '--notifications.connectors.default.email=notification-email', '--xpack.task_manager.allow_reading_invalid_state=false', '--xpack.task_manager.requeue_invalid_tasks.enabled=true', + '--xpack.actions.queued.max=500', ], }, }; diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts index 76ebd2cf20af9..c7104b3b489a7 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts @@ -344,6 +344,7 @@ export function defineRoutes( ) : null, params: req.body.params, + actionTypeId: req.params.id, }, ]); return res.noContent(); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts index 7a77ebcb1a432..1c735f75f5001 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts @@ -31,6 +31,7 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./type_not_enabled')); loadTestFile(require.resolve('./schedule_unsecured_action')); loadTestFile(require.resolve('./check_registered_connector_types')); + loadTestFile(require.resolve('./max_queued_actions_circuit_breaker')); // note that this test will destroy existing spaces loadTestFile(require.resolve('./migrations')); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/max_queued_actions_circuit_breaker.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/max_queued_actions_circuit_breaker.ts new file mode 100644 index 0000000000000..beeb7a35a13a0 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/max_queued_actions_circuit_breaker.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; +import { getEventLog, getTestRuleData, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createActionTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('max queued actions circuit breaker', () => { + const objectRemover = new ObjectRemover(supertest); + const retry = getService('retry'); + + after(() => objectRemover.removeAll()); + + it('completes execution and reports back whether it reached the limit', async () => { + const response = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'My action', + connector_type_id: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }); + + expect(response.status).to.eql(200); + const actionId = response.body.id; + objectRemover.add('default', actionId, 'action', 'actions'); + + const actions = []; + for (let i = 0; i < 510; i++) { + actions.push({ + id: actionId, + group: 'default', + params: { + index: ES_TEST_INDEX_NAME, + reference: 'test', + message: '', + }, + frequency: { + summary: false, + throttle: null, + notify_when: 'onActiveAlert', + }, + }); + } + + const resp = await supertest + .post('/api/alerting/rule') + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.always-firing-alert-as-data', + schedule: { interval: '1h' }, + throttle: undefined, + notify_when: undefined, + params: { + index: ES_TEST_INDEX_NAME, + reference: 'test', + }, + actions, + }) + ); + + expect(resp.status).to.eql(200); + const ruleId = resp.body.id; + objectRemover.add('default', ruleId, 'rule', 'alerting'); + + const events = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: 'default', + type: 'alert', + id: ruleId, + provider: 'alerting', + actions: new Map([['execute', { gte: 1 }]]), + }); + }); + + // check that there's a warning in the execute event + const executeEvent = events[0]; + expect(executeEvent?.event?.outcome).to.eql('success'); + expect(executeEvent?.event?.reason).to.eql('maxQueuedActions'); + expect(executeEvent?.kibana?.alerting?.status).to.eql('warning'); + expect(executeEvent?.message).to.eql( + 'The maximum number of queued actions was reached; excess actions were not triggered.' + ); + }); + }); +} From 3f18975c04814f322102dc98b99b2a8eb05eee6d Mon Sep 17 00:00:00 2001 From: Antonio Date: Thu, 7 Sep 2023 16:39:12 +0200 Subject: [PATCH 4/5] [Cases] Serverless tests for Cases detail view (#164827) Connected with #164552 ## Summary This PR creates a serverless version of the tests currently in `x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts`. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> --- .../common/lib/api/index.ts | 1 + .../services/cases/test_resources.ts | 7 +- .../cases/attachment_framework.ts | 2 +- .../observability/cases/configure.ts | 2 +- .../observability/cases/create_case_form.ts | 2 +- .../test_suites/observability/cases/empty.txt | 0 .../observability/cases/list_view.ts | 2 +- .../observability/cases/view_case.ts | 445 ++++++++++++++++++ .../test_suites/observability/index.ts | 3 +- .../ftr/cases/attachment_framework.ts | 2 +- .../security/ftr/cases/configure.ts | 2 +- .../security/ftr/cases/create_case_form.ts | 2 +- .../test_suites/security/ftr/cases/empty.txt | 0 .../security/ftr/cases/list_view.ts | 2 +- .../security/ftr/cases/view_case.ts | 444 +++++++++++++++++ .../functional/test_suites/security/index.ts | 3 +- .../shared/lib/assets/elastic_logo.png | Bin 0 -> 34043 bytes .../shared/lib/cases/helpers.ts | 51 ++ 18 files changed, 959 insertions(+), 11 deletions(-) create mode 100644 x-pack/test_serverless/functional/test_suites/observability/cases/empty.txt create mode 100644 x-pack/test_serverless/functional/test_suites/observability/cases/view_case.ts create mode 100644 x-pack/test_serverless/functional/test_suites/security/ftr/cases/empty.txt create mode 100644 x-pack/test_serverless/functional/test_suites/security/ftr/cases/view_case.ts create mode 100644 x-pack/test_serverless/shared/lib/assets/elastic_logo.png diff --git a/x-pack/test/cases_api_integration/common/lib/api/index.ts b/x-pack/test/cases_api_integration/common/lib/api/index.ts index f32a578103584..6a539457849e6 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/index.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/index.ts @@ -433,6 +433,7 @@ export const updateCase = async ({ const { body: cases } = await apiCall .set('kbn-xsrf', 'true') + .set('x-elastic-internal-origin', 'foo') .set(headers) .send(params) .expect(expectedHttpCode); diff --git a/x-pack/test/functional/services/cases/test_resources.ts b/x-pack/test/functional/services/cases/test_resources.ts index 135bf13a3a2d6..f3ef6ed29832e 100644 --- a/x-pack/test/functional/services/cases/test_resources.ts +++ b/x-pack/test/functional/services/cases/test_resources.ts @@ -12,13 +12,18 @@ export function CasesTestResourcesServiceProvider({ getService }: FtrProviderCon return { async installKibanaSampleData(sampleDataId: 'ecommerce' | 'flights' | 'logs') { - await supertest.post(`/api/sample_data/${sampleDataId}`).set('kbn-xsrf', 'true').expect(200); + await supertest + .post(`/api/sample_data/${sampleDataId}`) + .set('kbn-xsrf', 'true') + .set('x-elastic-internal-origin', 'foo') + .expect(200); }, async removeKibanaSampleData(sampleDataId: 'ecommerce' | 'flights' | 'logs') { await supertest .delete(`/api/sample_data/${sampleDataId}`) .set('kbn-xsrf', 'true') + .set('x-elastic-internal-origin', 'foo') .expect(204); }, }; diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/attachment_framework.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/attachment_framework.ts index e8be4ff1cf4d3..f1e7408ebcf68 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/attachment_framework.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/attachment_framework.ts @@ -19,7 +19,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const cases = getService('cases'); const find = getService('find'); - describe('persistable attachment', () => { + describe('Cases persistable attachments', () => { describe('lens visualization', () => { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts index bc007a7ad4b7b..a0d9cd0e96dd3 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/configure.ts @@ -16,7 +16,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const cases = getService('cases'); const toasts = getService('toasts'); - describe('Configure', function () { + describe('Configure Case', function () { before(async () => { await svlObltNavigation.navigateToLandingPage(); diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts index 5633538baa085..d7934f57dbc36 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/create_case_form.ts @@ -15,7 +15,7 @@ import { navigateToCasesApp } from '../../../../shared/lib/cases'; const owner = OBSERVABILITY_OWNER; export default ({ getService, getPageObject }: FtrProviderContext) => { - describe('Create case', function () { + describe('Create Case', function () { const find = getService('find'); const cases = getService('cases'); const testSubjects = getService('testSubjects'); diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/empty.txt b/x-pack/test_serverless/functional/test_suites/observability/cases/empty.txt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts index cb4aa44b09c35..d4dfdca15e82d 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/list_view.ts @@ -17,7 +17,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const svlCommonNavigation = getPageObject('svlCommonNavigation'); const svlObltNavigation = getService('svlObltNavigation'); - describe('cases list', () => { + describe('Cases list', () => { before(async () => { await svlObltNavigation.navigateToLandingPage(); await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'observability-overview:cases' }); diff --git a/x-pack/test_serverless/functional/test_suites/observability/cases/view_case.ts b/x-pack/test_serverless/functional/test_suites/observability/cases/view_case.ts new file mode 100644 index 0000000000000..97271fe33048b --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/observability/cases/view_case.ts @@ -0,0 +1,445 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { v4 as uuidv4 } from 'uuid'; +import { CaseSeverity, CaseStatuses } from '@kbn/cases-plugin/common/types/domain'; + +import { OBSERVABILITY_OWNER } from '@kbn/cases-plugin/common'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { + createOneCaseBeforeDeleteAllAfter, + createAndNavigateToCase, +} from '../../../../shared/lib/cases/helpers'; + +const owner = OBSERVABILITY_OWNER; + +export default ({ getPageObject, getService }: FtrProviderContext) => { + const header = getPageObject('header'); + const testSubjects = getService('testSubjects'); + const cases = getService('cases'); + const find = getService('find'); + + const retry = getService('retry'); + const comboBox = getService('comboBox'); + const svlCommonNavigation = getPageObject('svlCommonNavigation'); + + describe('Case View', () => { + describe('page', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('should show the case view page correctly', async () => { + await testSubjects.existOrFail('case-view-title'); + await testSubjects.existOrFail('header-page-supplements'); + + await testSubjects.existOrFail('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-title-files'); + await testSubjects.existOrFail('description'); + + await testSubjects.existOrFail('case-view-activity'); + + await testSubjects.existOrFail('case-view-assignees'); + await testSubjects.existOrFail('sidebar-severity'); + await testSubjects.existOrFail('case-view-user-list-reporter'); + await testSubjects.existOrFail('case-view-user-list-participants'); + await testSubjects.existOrFail('case-view-tag-list'); + await testSubjects.existOrFail('cases-categories'); + await testSubjects.existOrFail('sidebar-connectors'); + }); + }); + + describe('properties', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('edits a case title from the case view page', async () => { + const newTitle = `test-${uuidv4()}`; + + await testSubjects.click('editable-title-header-value'); + await testSubjects.setValue('editable-title-input-field', newTitle); + await testSubjects.click('editable-title-submit-btn'); + + // wait for backend response + await retry.tryForTime(5000, async () => { + const title = await find.byCssSelector('[data-test-subj="editable-title-header-value"]'); + expect(await title.getVisibleText()).equal(newTitle); + }); + + // validate user action + await find.byCssSelector('[data-test-subj*="title-update-action"]'); + }); + + it('adds a comment to a case', async () => { + const commentArea = await find.byCssSelector( + '[data-test-subj="add-comment"] textarea.euiMarkdownEditorTextArea' + ); + await commentArea.focus(); + await commentArea.type('Test comment from automation'); + + await testSubjects.click('submit-comment'); + + // validate user action + const newComment = await find.byCssSelector( + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' + ); + expect(await newComment.getVisibleText()).equal('Test comment from automation'); + }); + + it('adds a category to a case', async () => { + const category = uuidv4(); + await testSubjects.click('category-edit-button'); + await comboBox.setCustom('comboBoxInput', category); + await testSubjects.click('edit-category-submit'); + + // validate category was added + await testSubjects.existOrFail('category-viewer-' + category); + + // validate user action + await find.byCssSelector('[data-test-subj*="category-update-action"]'); + }); + + it('deletes a category from a case', async () => { + await find.byCssSelector('[data-test-subj*="category-viewer-"]'); + + await testSubjects.click('category-remove-button'); + + await testSubjects.existOrFail('no-categories'); + // validate user action + await find.byCssSelector('[data-test-subj*="category-delete-action"]'); + }); + + it('adds a tag to a case', async () => { + const tag = uuidv4(); + await testSubjects.click('tag-list-edit-button'); + await comboBox.setCustom('comboBoxInput', tag); + await testSubjects.click('edit-tags-submit'); + + // validate tag was added + await testSubjects.existOrFail('tag-' + tag); + + // validate user action + await find.byCssSelector('[data-test-subj*="tags-add-action"]'); + }); + + it('deletes a tag from a case', async () => { + await testSubjects.click('tag-list-edit-button'); + // find the tag button and click the close button + const button = await find.byCssSelector('[data-test-subj="comboBoxInput"] button'); + await button.click(); + await testSubjects.click('edit-tags-submit'); + + // validate user action + await find.byCssSelector('[data-test-subj*="tags-delete-action"]'); + }); + + describe('status', () => { + it('changes a case status to closed via dropdown-menu', async () => { + await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses.closed); + + // validate user action + await find.byCssSelector( + '[data-test-subj*="status-update-action"] [data-test-subj="case-status-badge-closed"]' + ); + // validates dropdown tag + await testSubjects.existOrFail( + 'case-view-status-dropdown > case-status-badge-popover-button-closed' + ); + }); + }); + + describe('Severity field', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('shows the severity field on the sidebar', async () => { + await testSubjects.existOrFail('case-severity-selection'); + }); + + it('changes the severity level from the selector', async () => { + await cases.common.selectSeverity(CaseSeverity.MEDIUM); + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('case-severity-selection-' + CaseSeverity.MEDIUM); + + // validate user action + await find.byCssSelector('[data-test-subj*="severity-update-action"]'); + }); + }); + }); + + describe('actions', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('deletes the case successfully', async () => { + await cases.singleCase.deleteCase(); + await cases.casesTable.waitForTableToFinishLoading(); + await cases.casesTable.validateCasesTableHasNthRows(0); + }); + }); + + describe('filter activity', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('filters by all by default', async () => { + const allBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-all"] span.euiNotificationBadge' + ); + + expect(await allBadge.getAttribute('aria-label')).equal('1 active filters'); + }); + + it('filters by comment successfully', async () => { + const commentBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-comments"] span.euiNotificationBadge' + ); + + expect(await commentBadge.getAttribute('aria-label')).equal('0 available filters'); + + const commentArea = await find.byCssSelector( + '[data-test-subj="add-comment"] textarea.euiMarkdownEditorTextArea' + ); + await commentArea.focus(); + await commentArea.type('Test comment from automation'); + await testSubjects.click('submit-comment'); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('user-actions-filter-activity-button-comments'); + + expect(await commentBadge.getAttribute('aria-label')).equal('1 active filters'); + }); + + it('filters by history successfully', async () => { + const historyBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-history"] span.euiNotificationBadge' + ); + + expect(await historyBadge.getAttribute('aria-label')).equal('1 available filters'); + + await cases.common.selectSeverity(CaseSeverity.MEDIUM); + + await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses['in-progress']); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('user-actions-filter-activity-button-history'); + + expect(await historyBadge.getAttribute('aria-label')).equal('3 active filters'); + }); + + it('sorts by newest first successfully', async () => { + await testSubjects.click('user-actions-filter-activity-button-all'); + + const AllBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-all"] span.euiNotificationBadge' + ); + + expect(await AllBadge.getVisibleText()).equal('4'); + + const sortDesc = await find.byCssSelector( + '[data-test-subj="user-actions-sort-select"] [value="desc"]' + ); + + await sortDesc.click(); + + await header.waitUntilLoadingHasFinished(); + + const userActionsLists = await find.allByCssSelector( + '[data-test-subj="user-actions-list"]' + ); + + const actionList = await userActionsLists[0].findAllByClassName('euiComment'); + + expect(await actionList[0].getAttribute('data-test-subj')).contain('status-update-action'); + }); + }); + + // FLAKY + describe.skip('Lens visualization', () => { + before(async () => { + await cases.testResources.installKibanaSampleData('logs'); + await createAndNavigateToCase(getPageObject, getService, owner); + }); + + after(async () => { + await cases.testResources.removeKibanaSampleData('logs'); + await cases.api.deleteAllCases(); + }); + + it('adds lens visualization in description', async () => { + await testSubjects.click('description-edit-icon'); + + await header.waitUntilLoadingHasFinished(); + + const editCommentTextArea = await find.byCssSelector( + '[data-test-subj*="editable-markdown-form"] textarea.euiMarkdownEditorTextArea' + ); + + await header.waitUntilLoadingHasFinished(); + + await editCommentTextArea.focus(); + + const editableDescription = await testSubjects.find('editable-markdown-form'); + + const addVisualizationButton = await editableDescription.findByCssSelector( + '[data-test-subj="euiMarkdownEditorToolbarButton"][aria-label="Visualization"]' + ); + await addVisualizationButton.click(); + + await cases.singleCase.findAndSaveVisualization('[Logs] Bytes distribution'); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('editable-save-markdown'); + + await header.waitUntilLoadingHasFinished(); + + const description = await find.byCssSelector('[data-test-subj="description"]'); + + await description.findByCssSelector('[data-test-subj="xyVisChart"]'); + }); + }); + + describe('pagination', async () => { + let createdCase: any; + + before(async () => { + createdCase = await createAndNavigateToCase(getPageObject, getService, owner); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); + + it('initially renders user actions list correctly', async () => { + expect(testSubjects.missingOrFail('cases-show-more-user-actions')); + + const userActionsLists = await find.allByCssSelector( + '[data-test-subj="user-actions-list"]' + ); + + expect(userActionsLists).length(1); + }); + + it('shows more actions on button click', async () => { + await cases.api.generateUserActions({ + caseId: createdCase.id, + caseVersion: createdCase.version, + totalUpdates: 4, + }); + + expect(testSubjects.missingOrFail('user-actions-loading')); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('case-refresh'); + + await header.waitUntilLoadingHasFinished(); + + expect(testSubjects.existOrFail('cases-show-more-user-actions')); + + const userActionsLists = await find.allByCssSelector( + '[data-test-subj="user-actions-list"]' + ); + + expect(userActionsLists).length(2); + + expect(await userActionsLists[0].findAllByClassName('euiComment')).length(10); + + expect(await userActionsLists[1].findAllByClassName('euiComment')).length(4); + + testSubjects.click('cases-show-more-user-actions'); + + await header.waitUntilLoadingHasFinished(); + + expect(await userActionsLists[0].findAllByClassName('euiComment')).length(20); + + expect(await userActionsLists[1].findAllByClassName('euiComment')).length(4); + }); + }); + + describe('Tabs', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('shows the "activity" tab by default', async () => { + await testSubjects.existOrFail('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-content-activity'); + }); + + it("shows the 'files' tab when clicked", async () => { + await testSubjects.click('case-view-tab-title-files'); + await testSubjects.existOrFail('case-view-tab-content-files'); + }); + }); + + describe('Files', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('adds a file to the case', async () => { + await testSubjects.click('case-view-tab-title-files'); + await testSubjects.existOrFail('case-view-tab-content-files'); + + await cases.casesFilesTable.addFile(require.resolve('./empty.txt')); + + const uploadedFileName = await testSubjects.getVisibleText('cases-files-name-text'); + expect(uploadedFileName).to.be('empty.txt'); + }); + + it('search by file name', async () => { + await cases.casesFilesTable.searchByFileName('foobar'); + await cases.casesFilesTable.emptyOrFail(); + await cases.casesFilesTable.searchByFileName('empty'); + + const uploadedFileName = await testSubjects.getVisibleText('cases-files-name-text'); + expect(uploadedFileName).to.be('empty.txt'); + }); + + it('files added to a case can be deleted', async () => { + await cases.casesFilesTable.deleteFile(0); + await cases.casesFilesTable.emptyOrFail(); + }); + + describe('Files User Activity', () => { + it('file user action is displayed correctly', async () => { + await cases.casesFilesTable.addFile(require.resolve('./empty.txt')); + + await testSubjects.click('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-content-activity'); + + const uploadedFileName = await testSubjects.getVisibleText('cases-files-name-text'); + expect(uploadedFileName).to.be('empty.txt'); + }); + }); + }); + + describe('breadcrumbs', () => { + let createdCase: any; + + before(async () => { + createdCase = await createAndNavigateToCase(getPageObject, getService, owner); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); + + it('should set the cases title', async () => { + svlCommonNavigation.breadcrumbs.expectExists(); + svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ text: createdCase.title }); + }); + }); + + describe('reporter', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('should render the reporter correctly', async () => { + const reporter = await cases.singleCase.getReporter(); + + const reporterText = await reporter.getVisibleText(); + + expect(reporterText).to.be('elastic_serverless'); + }); + }); + }); +}; diff --git a/x-pack/test_serverless/functional/test_suites/observability/index.ts b/x-pack/test_serverless/functional/test_suites/observability/index.ts index e24841e6fbff9..2d4c664085dbb 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/index.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/index.ts @@ -13,8 +13,9 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./navigation')); loadTestFile(require.resolve('./observability_log_explorer')); loadTestFile(require.resolve('./cases/attachment_framework')); + loadTestFile(require.resolve('./cases/view_case')); loadTestFile(require.resolve('./cases/configure')); - loadTestFile(require.resolve('./cases/list_view')); loadTestFile(require.resolve('./cases/create_case_form')); + loadTestFile(require.resolve('./cases/list_view')); }); } diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/attachment_framework.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/attachment_framework.ts index c213e48348b67..6fb8c2ae94e44 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/attachment_framework.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/attachment_framework.ts @@ -20,7 +20,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // Failing // Issue: https://github.com/elastic/kibana/issues/165135 - describe.skip('persistable attachment', () => { + describe.skip('Cases persistable attachments', () => { describe('lens visualization', () => { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts index 58931d60d6836..564f14e8353c9 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/configure.ts @@ -15,7 +15,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const cases = getService('cases'); const toasts = getService('toasts'); - describe('Configure', function () { + describe('Configure Case', function () { before(async () => { await svlSecNavigation.navigateToLandingPage(); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts index d9d05fcb7420a..0e3fb3d57708a 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/create_case_form.ts @@ -15,7 +15,7 @@ import { navigateToCasesApp } from '../../../../../shared/lib/cases'; const owner = SECURITY_SOLUTION_OWNER; export default ({ getService, getPageObject }: FtrProviderContext) => { - describe('Create case', function () { + describe('Create Case', function () { const find = getService('find'); const cases = getService('cases'); const testSubjects = getService('testSubjects'); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/empty.txt b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/empty.txt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/list_view.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/list_view.ts index 6854b3df61061..e05c16551982b 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/list_view.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/list_view.ts @@ -16,7 +16,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const cases = getService('cases'); const svlSecNavigation = getService('svlSecNavigation'); - describe('cases list', () => { + describe('Cases List', () => { before(async () => { await svlSecNavigation.navigateToLandingPage(); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cases/view_case.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/view_case.ts new file mode 100644 index 0000000000000..bd0e496a1ba4d --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cases/view_case.ts @@ -0,0 +1,444 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { v4 as uuidv4 } from 'uuid'; +import { CaseSeverity, CaseStatuses } from '@kbn/cases-plugin/common/types/domain'; + +import { SECURITY_SOLUTION_OWNER } from '@kbn/cases-plugin/common'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { + createOneCaseBeforeDeleteAllAfter, + createAndNavigateToCase, +} from '../../../../../shared/lib/cases/helpers'; + +const owner = SECURITY_SOLUTION_OWNER; + +export default ({ getPageObject, getService }: FtrProviderContext) => { + const header = getPageObject('header'); + const testSubjects = getService('testSubjects'); + const cases = getService('cases'); + const find = getService('find'); + + const retry = getService('retry'); + const comboBox = getService('comboBox'); + const svlCommonNavigation = getPageObject('svlCommonNavigation'); + + describe('Case View', () => { + describe('page', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('should show the case view page correctly', async () => { + await testSubjects.existOrFail('case-view-title'); + await testSubjects.existOrFail('header-page-supplements'); + + await testSubjects.existOrFail('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-title-files'); + await testSubjects.existOrFail('description'); + + await testSubjects.existOrFail('case-view-activity'); + + await testSubjects.existOrFail('case-view-assignees'); + await testSubjects.existOrFail('sidebar-severity'); + await testSubjects.existOrFail('case-view-user-list-reporter'); + await testSubjects.existOrFail('case-view-user-list-participants'); + await testSubjects.existOrFail('case-view-tag-list'); + await testSubjects.existOrFail('cases-categories'); + await testSubjects.existOrFail('sidebar-connectors'); + }); + }); + + describe('properties', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('edits a case title from the case view page', async () => { + const newTitle = `test-${uuidv4()}`; + + await testSubjects.click('editable-title-header-value'); + await testSubjects.setValue('editable-title-input-field', newTitle); + await testSubjects.click('editable-title-submit-btn'); + + // wait for backend response + await retry.tryForTime(5000, async () => { + const title = await find.byCssSelector('[data-test-subj="editable-title-header-value"]'); + expect(await title.getVisibleText()).equal(newTitle); + }); + + // validate user action + await find.byCssSelector('[data-test-subj*="title-update-action"]'); + }); + + it('adds a comment to a case', async () => { + const commentArea = await find.byCssSelector( + '[data-test-subj="add-comment"] textarea.euiMarkdownEditorTextArea' + ); + await commentArea.focus(); + await commentArea.type('Test comment from automation'); + + await testSubjects.click('submit-comment'); + + // validate user action + const newComment = await find.byCssSelector( + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' + ); + expect(await newComment.getVisibleText()).equal('Test comment from automation'); + }); + + it('adds a category to a case', async () => { + const category = uuidv4(); + await testSubjects.click('category-edit-button'); + await comboBox.setCustom('comboBoxInput', category); + await testSubjects.click('edit-category-submit'); + + // validate category was added + await testSubjects.existOrFail('category-viewer-' + category); + + // validate user action + await find.byCssSelector('[data-test-subj*="category-update-action"]'); + }); + + it('deletes a category from a case', async () => { + await find.byCssSelector('[data-test-subj*="category-viewer-"]'); + + await testSubjects.click('category-remove-button'); + + await testSubjects.existOrFail('no-categories'); + // validate user action + await find.byCssSelector('[data-test-subj*="category-delete-action"]'); + }); + + it('adds a tag to a case', async () => { + const tag = uuidv4(); + await testSubjects.click('tag-list-edit-button'); + await comboBox.setCustom('comboBoxInput', tag); + await testSubjects.click('edit-tags-submit'); + + // validate tag was added + await testSubjects.existOrFail('tag-' + tag); + + // validate user action + await find.byCssSelector('[data-test-subj*="tags-add-action"]'); + }); + + it('deletes a tag from a case', async () => { + await testSubjects.click('tag-list-edit-button'); + // find the tag button and click the close button + const button = await find.byCssSelector('[data-test-subj="comboBoxInput"] button'); + await button.click(); + await testSubjects.click('edit-tags-submit'); + + // validate user action + await find.byCssSelector('[data-test-subj*="tags-delete-action"]'); + }); + + describe('status', () => { + it('changes a case status to in-progress via dropdown menu', async () => { + await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses['in-progress']); + // validate user action + await find.byCssSelector( + '[data-test-subj*="status-update-action"] [data-test-subj="case-status-badge-in-progress"]' + ); + // validates dropdown tag + await testSubjects.existOrFail( + 'case-view-status-dropdown > case-status-badge-popover-button-in-progress' + ); + }); + }); + + describe('Severity field', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('shows the severity field on the sidebar', async () => { + await testSubjects.existOrFail('case-severity-selection'); + }); + + it('changes the severity level from the selector', async () => { + await cases.common.selectSeverity(CaseSeverity.MEDIUM); + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('case-severity-selection-' + CaseSeverity.MEDIUM); + + // validate user action + await find.byCssSelector('[data-test-subj*="severity-update-action"]'); + }); + }); + }); + + describe('actions', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('deletes the case successfully', async () => { + await cases.singleCase.deleteCase(); + await cases.casesTable.waitForTableToFinishLoading(); + await cases.casesTable.validateCasesTableHasNthRows(0); + }); + }); + + describe('filter activity', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('filters by all by default', async () => { + const allBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-all"] span.euiNotificationBadge' + ); + + expect(await allBadge.getAttribute('aria-label')).equal('1 active filters'); + }); + + it('filters by comment successfully', async () => { + const commentBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-comments"] span.euiNotificationBadge' + ); + + expect(await commentBadge.getAttribute('aria-label')).equal('0 available filters'); + + const commentArea = await find.byCssSelector( + '[data-test-subj="add-comment"] textarea.euiMarkdownEditorTextArea' + ); + await commentArea.focus(); + await commentArea.type('Test comment from automation'); + await testSubjects.click('submit-comment'); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('user-actions-filter-activity-button-comments'); + + expect(await commentBadge.getAttribute('aria-label')).equal('1 active filters'); + }); + + it('filters by history successfully', async () => { + const historyBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-history"] span.euiNotificationBadge' + ); + + expect(await historyBadge.getAttribute('aria-label')).equal('1 available filters'); + + await cases.common.selectSeverity(CaseSeverity.MEDIUM); + + await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses['in-progress']); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('user-actions-filter-activity-button-history'); + + expect(await historyBadge.getAttribute('aria-label')).equal('3 active filters'); + }); + + it('sorts by newest first successfully', async () => { + await testSubjects.click('user-actions-filter-activity-button-all'); + + const AllBadge = await find.byCssSelector( + '[data-test-subj="user-actions-filter-activity-button-all"] span.euiNotificationBadge' + ); + + expect(await AllBadge.getVisibleText()).equal('4'); + + const sortDesc = await find.byCssSelector( + '[data-test-subj="user-actions-sort-select"] [value="desc"]' + ); + + await sortDesc.click(); + + await header.waitUntilLoadingHasFinished(); + + const userActionsLists = await find.allByCssSelector( + '[data-test-subj="user-actions-list"]' + ); + + const actionList = await userActionsLists[0].findAllByClassName('euiComment'); + + expect(await actionList[0].getAttribute('data-test-subj')).contain('status-update-action'); + }); + }); + + // FLAKY + describe.skip('Lens visualization', () => { + before(async () => { + await cases.testResources.installKibanaSampleData('logs'); + await createAndNavigateToCase(getPageObject, getService, owner); + }); + + after(async () => { + await cases.testResources.removeKibanaSampleData('logs'); + await cases.api.deleteAllCases(); + }); + + it('adds lens visualization in description', async () => { + await testSubjects.click('description-edit-icon'); + + await header.waitUntilLoadingHasFinished(); + + const editCommentTextArea = await find.byCssSelector( + '[data-test-subj*="editable-markdown-form"] textarea.euiMarkdownEditorTextArea' + ); + + await header.waitUntilLoadingHasFinished(); + + await editCommentTextArea.focus(); + + const editableDescription = await testSubjects.find('editable-markdown-form'); + + const addVisualizationButton = await editableDescription.findByCssSelector( + '[data-test-subj="euiMarkdownEditorToolbarButton"][aria-label="Visualization"]' + ); + await addVisualizationButton.click(); + + await cases.singleCase.findAndSaveVisualization('[Logs] Bytes distribution'); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('editable-save-markdown'); + + await header.waitUntilLoadingHasFinished(); + + const description = await find.byCssSelector('[data-test-subj="description"]'); + + await description.findByCssSelector('[data-test-subj="xyVisChart"]'); + }); + }); + + describe('pagination', async () => { + let createdCase: any; + + before(async () => { + createdCase = await createAndNavigateToCase(getPageObject, getService, owner); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); + + it('initially renders user actions list correctly', async () => { + expect(testSubjects.missingOrFail('cases-show-more-user-actions')); + + const userActionsLists = await find.allByCssSelector( + '[data-test-subj="user-actions-list"]' + ); + + expect(userActionsLists).length(1); + }); + + it('shows more actions on button click', async () => { + await cases.api.generateUserActions({ + caseId: createdCase.id, + caseVersion: createdCase.version, + totalUpdates: 4, + }); + + expect(testSubjects.missingOrFail('user-actions-loading')); + + await header.waitUntilLoadingHasFinished(); + + await testSubjects.click('case-refresh'); + + await header.waitUntilLoadingHasFinished(); + + expect(testSubjects.existOrFail('cases-show-more-user-actions')); + + const userActionsLists = await find.allByCssSelector( + '[data-test-subj="user-actions-list"]' + ); + + expect(userActionsLists).length(2); + + expect(await userActionsLists[0].findAllByClassName('euiComment')).length(10); + + expect(await userActionsLists[1].findAllByClassName('euiComment')).length(4); + + testSubjects.click('cases-show-more-user-actions'); + + await header.waitUntilLoadingHasFinished(); + + expect(await userActionsLists[0].findAllByClassName('euiComment')).length(20); + + expect(await userActionsLists[1].findAllByClassName('euiComment')).length(4); + }); + }); + + describe('Tabs', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('shows the "activity" tab by default', async () => { + await testSubjects.existOrFail('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-content-activity'); + }); + + it("shows the 'files' tab when clicked", async () => { + await testSubjects.click('case-view-tab-title-files'); + await testSubjects.existOrFail('case-view-tab-content-files'); + }); + }); + + describe('Files', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('adds a file to the case', async () => { + await testSubjects.click('case-view-tab-title-files'); + await testSubjects.existOrFail('case-view-tab-content-files'); + + await cases.casesFilesTable.addFile(require.resolve('./empty.txt')); + + const uploadedFileName = await testSubjects.getVisibleText('cases-files-name-text'); + expect(uploadedFileName).to.be('empty.txt'); + }); + + it('search by file name', async () => { + await cases.casesFilesTable.searchByFileName('foobar'); + await cases.casesFilesTable.emptyOrFail(); + await cases.casesFilesTable.searchByFileName('empty'); + + const uploadedFileName = await testSubjects.getVisibleText('cases-files-name-text'); + expect(uploadedFileName).to.be('empty.txt'); + }); + + it('files added to a case can be deleted', async () => { + await cases.casesFilesTable.deleteFile(0); + await cases.casesFilesTable.emptyOrFail(); + }); + + describe('Files User Activity', () => { + it('file user action is displayed correctly', async () => { + await cases.casesFilesTable.addFile(require.resolve('./empty.txt')); + + await testSubjects.click('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-content-activity'); + + const uploadedFileName = await testSubjects.getVisibleText('cases-files-name-text'); + expect(uploadedFileName).to.be('empty.txt'); + }); + }); + }); + + describe('breadcrumbs', () => { + let createdCase: any; + + before(async () => { + createdCase = await createAndNavigateToCase(getPageObject, getService, owner); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); + + it('should set the cases title', async () => { + svlCommonNavigation.breadcrumbs.expectExists(); + svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ text: createdCase.title }); + }); + }); + + describe('reporter', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService, owner); + + it('should render the reporter correctly', async () => { + const reporter = await cases.singleCase.getReporter(); + + const reporterText = await reporter.getVisibleText(); + + expect(reporterText).to.be('elastic_serverless'); + }); + }); + }); +}; diff --git a/x-pack/test_serverless/functional/test_suites/security/index.ts b/x-pack/test_serverless/functional/test_suites/security/index.ts index f64b4b8395dad..b4c46b5f4c263 100644 --- a/x-pack/test_serverless/functional/test_suites/security/index.ts +++ b/x-pack/test_serverless/functional/test_suites/security/index.ts @@ -13,8 +13,9 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./ftr/navigation')); loadTestFile(require.resolve('./ftr/management')); loadTestFile(require.resolve('./ftr/cases/attachment_framework')); - loadTestFile(require.resolve('./ftr/cases/list_view')); + loadTestFile(require.resolve('./ftr/cases/view_case')); loadTestFile(require.resolve('./ftr/cases/create_case_form')); loadTestFile(require.resolve('./ftr/cases/configure')); + loadTestFile(require.resolve('./ftr/cases/list_view')); }); } diff --git a/x-pack/test_serverless/shared/lib/assets/elastic_logo.png b/x-pack/test_serverless/shared/lib/assets/elastic_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..085012eac378818bf04ffde92a9950243942003d GIT binary patch literal 34043 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 literal 0 HcmV?d00001 diff --git a/x-pack/test_serverless/shared/lib/cases/helpers.ts b/x-pack/test_serverless/shared/lib/cases/helpers.ts index cbae461f98ca5..98dcfb6d31ebc 100644 --- a/x-pack/test_serverless/shared/lib/cases/helpers.ts +++ b/x-pack/test_serverless/shared/lib/cases/helpers.ts @@ -8,6 +8,57 @@ import { SECURITY_SOLUTION_OWNER } from '@kbn/cases-plugin/common'; import { FtrProviderContext } from '../../../functional/ftr_provider_context'; +export const createOneCaseBeforeDeleteAllAfter = ( + getPageObject: FtrProviderContext['getPageObject'], + getService: FtrProviderContext['getService'], + owner: string +) => { + const cases = getService('cases'); + + before(async () => { + await createAndNavigateToCase(getPageObject, getService, owner); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); +}; + +export const createOneCaseBeforeEachDeleteAllAfterEach = ( + getPageObject: FtrProviderContext['getPageObject'], + getService: FtrProviderContext['getService'], + owner: string +) => { + const cases = getService('cases'); + + beforeEach(async () => { + await createAndNavigateToCase(getPageObject, getService, owner); + }); + + afterEach(async () => { + await cases.api.deleteAllCases(); + }); +}; + +export const createAndNavigateToCase = async ( + getPageObject: FtrProviderContext['getPageObject'], + getService: FtrProviderContext['getService'], + owner: string +) => { + const cases = getService('cases'); + + const header = getPageObject('header'); + + await navigateToCasesApp(getPageObject, getService, owner); + + const theCase = await cases.api.createCase({ owner }); + await cases.casesTable.waitForCasesToBeListed(); + await cases.casesTable.goToFirstListedCase(); + await header.waitUntilLoadingHasFinished(); + + return theCase; +}; + export const navigateToCasesApp = async ( getPageObject: FtrProviderContext['getPageObject'], getService: FtrProviderContext['getService'], From ed48990395c639a49370a829345d22d89f24522f Mon Sep 17 00:00:00 2001 From: Philippe Oberti Date: Thu, 7 Sep 2023 16:49:10 +0200 Subject: [PATCH 5/5] [Security Solution] expandable flyout - add isolate host panel (#165933) ## Summary This new expandable flyout is going GA in `8.10`. One feature isn't working: the `isolate host` from the `take action` button in the right section footer. The code was added in this [PR](https://github.com/elastic/kibana/pull/153903) but isolate host testing must have been overlooked. This PR adds the functionality to the new expandable flyout, by creating a new panel, displayed similarly to the right panel is today. https://github.com/elastic/kibana/assets/17276605/abd99323-616b-4474-a21c-29ce3c56dd1a https://github.com/elastic/kibana/pull/165933 ### TODO - [ ] verify logic - [ ] add unit tests - [ ] add Cypress tests ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Ashokaditya --- .../security_solution/public/flyout/index.tsx | 11 ++ .../public/flyout/isolate_host/content.tsx | 62 +++++++++ .../public/flyout/isolate_host/context.tsx | 122 ++++++++++++++++++ .../public/flyout/isolate_host/header.tsx | 31 +++++ .../public/flyout/isolate_host/index.tsx | 37 ++++++ .../public/flyout/isolate_host/test_ids.ts | 8 ++ .../flyout/isolate_host/translations.ts | 22 ++++ .../public/flyout/right/footer.tsx | 38 ++++-- 8 files changed, 322 insertions(+), 9 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/flyout/isolate_host/content.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/isolate_host/context.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/isolate_host/header.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/isolate_host/index.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/isolate_host/test_ids.ts create mode 100644 x-pack/plugins/security_solution/public/flyout/isolate_host/translations.ts diff --git a/x-pack/plugins/security_solution/public/flyout/index.tsx b/x-pack/plugins/security_solution/public/flyout/index.tsx index 922c7e55219e0..c5da39105d929 100644 --- a/x-pack/plugins/security_solution/public/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/index.tsx @@ -11,6 +11,9 @@ import { type ExpandableFlyoutProps, ExpandableFlyoutProvider, } from '@kbn/expandable-flyout'; +import type { IsolateHostPanelProps } from './isolate_host'; +import { IsolateHostPanel, IsolateHostPanelKey } from './isolate_host'; +import { IsolateHostPanelProvider } from './isolate_host/context'; import type { RightPanelProps } from './right'; import { RightPanel, RightPanelKey } from './right'; import { RightPanelProvider } from './right/context'; @@ -54,6 +57,14 @@ const expandableFlyoutDocumentsPanels: ExpandableFlyoutProps['registeredPanels'] ), }, + { + key: IsolateHostPanelKey, + component: (props) => ( + + + + ), + }, ]; const OuterProviders: FC = ({ children }) => { diff --git a/x-pack/plugins/security_solution/public/flyout/isolate_host/content.tsx b/x-pack/plugins/security_solution/public/flyout/isolate_host/content.tsx new file mode 100644 index 0000000000000..7f3671cc60805 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/isolate_host/content.tsx @@ -0,0 +1,62 @@ +/* + * 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 type { FC } from 'react'; +import React, { useCallback } from 'react'; +import { useExpandableFlyoutContext } from '@kbn/expandable-flyout'; +import { EuiPanel } from '@elastic/eui'; +import { RightPanelKey } from '../right'; +import { useBasicDataFromDetailsData } from '../../timelines/components/side_panel/event_details/helpers'; +import { EndpointIsolateSuccess } from '../../common/components/endpoint/host_isolation'; +import { useHostIsolationTools } from '../../timelines/components/side_panel/event_details/use_host_isolation_tools'; +import { useIsolateHostPanelContext } from './context'; +import { HostIsolationPanel } from '../../detections/components/host_isolation'; + +/** + * Document details expandable flyout section content for the isolate host component, displaying the form or the success banner + */ +export const PanelContent: FC = () => { + const { openRightPanel } = useExpandableFlyoutContext(); + const { dataFormattedForFieldBrowser, eventId, scopeId, indexName, isolateAction } = + useIsolateHostPanelContext(); + + const { isIsolateActionSuccessBannerVisible, handleIsolationActionSuccess } = + useHostIsolationTools(); + + const { alertId, hostName } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); + + const showAlertDetails = useCallback( + () => + openRightPanel({ + id: RightPanelKey, + params: { + id: eventId, + indexName, + scopeId, + }, + }), + [eventId, indexName, scopeId, openRightPanel] + ); + + return ( + + {isIsolateActionSuccessBannerVisible && ( + + )} + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/isolate_host/context.tsx b/x-pack/plugins/security_solution/public/flyout/isolate_host/context.tsx new file mode 100644 index 0000000000000..6451437646a53 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/isolate_host/context.tsx @@ -0,0 +1,122 @@ +/* + * 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 type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; +import { css } from '@emotion/react'; +import React, { createContext, memo, useContext, useMemo } from 'react'; +import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; + +import { useTimelineEventsDetails } from '../../timelines/containers/details'; +import { getAlertIndexAlias } from '../../timelines/components/side_panel/event_details/helpers'; +import { useSpaceId } from '../../common/hooks/use_space_id'; +import { useRouteSpy } from '../../common/utils/route/use_route_spy'; +import { SecurityPageName } from '../../../common/constants'; +import { SourcererScopeName } from '../../common/store/sourcerer/model'; +import { useSourcererDataView } from '../../common/containers/sourcerer'; +import type { IsolateHostPanelProps } from '.'; + +export interface IsolateHostPanelContext { + /** + * Id of the document + */ + eventId: string; + /** + * Name of the index used in the parent's page + */ + indexName: string; + /** + * Maintain backwards compatibility // TODO remove when possible + */ + scopeId: string; + /** + * An array of field objects with category and value + */ + dataFormattedForFieldBrowser: TimelineEventsDetailsItem[] | null; + /** + * Isolate action, either 'isolateHost' or 'unisolateHost' + */ + isolateAction: 'isolateHost' | 'unisolateHost'; +} + +export const IsolateHostPanelContext = createContext( + undefined +); + +export type IsolateHostPanelProviderProps = { + /** + * React components to render + */ + children: React.ReactNode; +} & Partial; + +export const IsolateHostPanelProvider = memo( + ({ id, indexName, scopeId, isolateAction, children }: IsolateHostPanelProviderProps) => { + const currentSpaceId = useSpaceId(); + // TODO Replace getAlertIndexAlias way to retrieving the eventIndex with the GET /_alias + // https://github.com/elastic/kibana/issues/113063 + const eventIndex = indexName ? getAlertIndexAlias(indexName, currentSpaceId) ?? indexName : ''; + const [{ pageName }] = useRouteSpy(); + const sourcererScope = + pageName === SecurityPageName.detections + ? SourcererScopeName.detections + : SourcererScopeName.default; + const sourcererDataView = useSourcererDataView(sourcererScope); + const [loading, dataFormattedForFieldBrowser] = useTimelineEventsDetails({ + indexName: eventIndex, + eventId: id ?? '', + runtimeMappings: sourcererDataView.runtimeMappings, + skip: !id, + }); + + const contextValue = useMemo( + () => + id && indexName && scopeId && isolateAction + ? { + eventId: id, + indexName, + scopeId, + dataFormattedForFieldBrowser, + isolateAction, + } + : undefined, + [id, indexName, scopeId, dataFormattedForFieldBrowser, isolateAction] + ); + + if (loading) { + return ( + + + + ); + } + + return ( + + {children} + + ); + } +); + +IsolateHostPanelProvider.displayName = 'IsolateHostPanelProvider'; + +export const useIsolateHostPanelContext = (): IsolateHostPanelContext => { + const contextValue = useContext(IsolateHostPanelContext); + + if (!contextValue) { + throw new Error( + 'IsolateHostPanelContext can only be used within IsolateHostPanelContext provider' + ); + } + + return contextValue; +}; diff --git a/x-pack/plugins/security_solution/public/flyout/isolate_host/header.tsx b/x-pack/plugins/security_solution/public/flyout/isolate_host/header.tsx new file mode 100644 index 0000000000000..168175878d802 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/isolate_host/header.tsx @@ -0,0 +1,31 @@ +/* + * 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 { EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; +import type { FC } from 'react'; +import React from 'react'; +import { useIsolateHostPanelContext } from './context'; +import { FLYOUT_HEADER_TITLE_TEST_ID } from './test_ids'; +import { PANEL_HEADER_ISOLATE_TITLE, PANEL_HEADER_RELEASE_TITLE } from './translations'; + +/** + * Document details expandable right section header for the isolate host panel + */ +export const PanelHeader: FC = () => { + const { isolateAction } = useIsolateHostPanelContext(); + + const title = + isolateAction === 'isolateHost' ? PANEL_HEADER_ISOLATE_TITLE : PANEL_HEADER_RELEASE_TITLE; + + return ( + + +

{title}

+
+
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/isolate_host/index.tsx b/x-pack/plugins/security_solution/public/flyout/isolate_host/index.tsx new file mode 100644 index 0000000000000..ff02d7b78a115 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/isolate_host/index.tsx @@ -0,0 +1,37 @@ +/* + * 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 type { FC } from 'react'; +import React from 'react'; +import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; +import { PanelContent } from './content'; +import { PanelHeader } from './header'; + +export const IsolateHostPanelKey: IsolateHostPanelProps['key'] = 'document-details-isolate-host'; + +export interface IsolateHostPanelProps extends FlyoutPanelProps { + key: 'document-details-isolate-host'; + params?: { + id: string; + indexName: string; + scopeId: string; + isolateAction: 'isolateHost' | 'unisolateHost' | undefined; + }; +} + +/** + * Panel to be displayed right section in the document details expandable flyout when isolate host is clicked in the + * take action button + */ +export const IsolateHostPanel: FC> = () => { + return ( + <> + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/isolate_host/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/isolate_host/test_ids.ts new file mode 100644 index 0000000000000..a40891c2c3d63 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/isolate_host/test_ids.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const FLYOUT_HEADER_TITLE_TEST_ID = 'securitySolutionDocumentDetailsFlyoutHeaderTitle'; diff --git a/x-pack/plugins/security_solution/public/flyout/isolate_host/translations.ts b/x-pack/plugins/security_solution/public/flyout/isolate_host/translations.ts new file mode 100644 index 0000000000000..84ec8d62c09de --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/isolate_host/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const PANEL_HEADER_ISOLATE_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.documentDetails.isolateHostPanelHeaderIsolateTitle', + { + defaultMessage: `Isolate host`, + } +); + +export const PANEL_HEADER_RELEASE_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.documentDetails.isolateHostPanelHeaderReleaseTitle', + { + defaultMessage: `Release host`, + } +); diff --git a/x-pack/plugins/security_solution/public/flyout/right/footer.tsx b/x-pack/plugins/security_solution/public/flyout/right/footer.tsx index d0980141ebfcc..b411470ee386f 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/footer.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/footer.tsx @@ -6,7 +6,7 @@ */ import type { FC } from 'react'; -import React, { memo } from 'react'; +import React, { useCallback } from 'react'; import { useExpandableFlyoutContext } from '@kbn/expandable-flyout'; import { FlyoutFooter } from '../../timelines/components/side_panel/event_details/flyout'; import { useRightPanelContext } from './context'; @@ -15,13 +15,35 @@ import { useHostIsolationTools } from '../../timelines/components/side_panel/eve /** * */ -export const PanelFooter: FC = memo(() => { - const { closeFlyout } = useExpandableFlyoutContext(); - const { dataFormattedForFieldBrowser, dataAsNestedObject, refetchFlyoutData, scopeId } = - useRightPanelContext(); +export const PanelFooter: FC = () => { + const { closeFlyout, openRightPanel } = useExpandableFlyoutContext(); + const { + eventId, + indexName, + dataFormattedForFieldBrowser, + dataAsNestedObject, + refetchFlyoutData, + scopeId, + } = useRightPanelContext(); const { isHostIsolationPanelOpen, showHostIsolationPanel } = useHostIsolationTools(); + const showHostIsolationPanelCallback = useCallback( + (action: 'isolateHost' | 'unisolateHost' | undefined) => { + showHostIsolationPanel(action); + openRightPanel({ + id: 'document-details-isolate-host', + params: { + id: eventId, + indexName, + scopeId, + isolateAction: action, + }, + }); + }, + [eventId, indexName, openRightPanel, scopeId, showHostIsolationPanel] + ); + if (!dataFormattedForFieldBrowser || !dataAsNestedObject) { return null; } @@ -34,11 +56,9 @@ export const PanelFooter: FC = memo(() => { isHostIsolationPanelOpen={isHostIsolationPanelOpen} isReadOnly={false} loadingEventDetails={false} - onAddIsolationStatusClick={showHostIsolationPanel} + onAddIsolationStatusClick={showHostIsolationPanelCallback} scopeId={scopeId} refetchFlyoutData={refetchFlyoutData} /> ); -}); - -PanelFooter.displayName = 'PanelFooter'; +};