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/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'; +}; 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.' + ); + }); + }); +} 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/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..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 @@ -51,7 +51,9 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { await PageObjects.header.waitUntilLoadingHasFinished(); } - describe('Fields existence info', () => { + // 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( 'test/api_integration/fixtures/es_archiver/index_patterns/constant_keyword' 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 0000000000000..085012eac3788 Binary files /dev/null and b/x-pack/test_serverless/shared/lib/assets/elastic_logo.png differ 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'],