diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 92a6ab2729c82e..0f7763379e5604 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -199,6 +199,22 @@ Specifies the time allowed for requests to external resources. Requests that tak + For example, `20m`, `24h`, `7d`, `1w`. Default: `60s`. +`xpack.actions.run.maxAttempts` {ess-icon}:: +Specifies the maximum number of times an action can be attempted to run. Can be minimum 1 and maximum 10. + +`xpack.actions.run.connectorTypeOverrides` {ess-icon}:: +Overrides the configs under `xpack.actions.run` for the connector type with the given ID. List the connector type identifier and its settings in an array of objects. ++ +For example: +[source,yaml] +-- +xpack.actions.run: + maxAttempts: 1 + connectorTypeOverrides: + - id: '.server-log' + maxAttempts: 5 +-- + [float] [[alert-settings]] ==== Alerting settings diff --git a/x-pack/plugins/actions/server/action_type_registry.test.ts b/x-pack/plugins/actions/server/action_type_registry.test.ts index fc945c57a8a1c0..3a7c04da549247 100644 --- a/x-pack/plugins/actions/server/action_type_registry.test.ts +++ b/x-pack/plugins/actions/server/action_type_registry.test.ts @@ -21,50 +21,51 @@ let mockedLicenseState: jest.Mocked; let mockedActionsConfig: jest.Mocked; let actionTypeRegistryParams: ActionTypeRegistryOpts; -beforeEach(() => { - jest.resetAllMocks(); - mockedLicenseState = licenseStateMock.create(); - mockedActionsConfig = actionsConfigMock.create(); - actionTypeRegistryParams = { - licensing: licensingMock.createSetup(), - taskManager: mockTaskManager, - taskRunnerFactory: new TaskRunnerFactory( - new ActionExecutor({ isESOCanEncrypt: true }), - inMemoryMetrics - ), - actionsConfigUtils: mockedActionsConfig, - licenseState: mockedLicenseState, - preconfiguredActions: [ - { - actionTypeId: 'foo', - config: {}, - id: 'my-slack1', - name: 'Slack #xyz', - secrets: {}, - isPreconfigured: true, - isDeprecated: false, - }, - ], +describe('actionTypeRegistry', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedLicenseState = licenseStateMock.create(); + mockedActionsConfig = actionsConfigMock.create(); + actionTypeRegistryParams = { + licensing: licensingMock.createSetup(), + taskManager: mockTaskManager, + taskRunnerFactory: new TaskRunnerFactory( + new ActionExecutor({ isESOCanEncrypt: true }), + inMemoryMetrics + ), + actionsConfigUtils: mockedActionsConfig, + licenseState: mockedLicenseState, + preconfiguredActions: [ + { + actionTypeId: 'foo', + config: {}, + id: 'my-slack1', + name: 'Slack #xyz', + secrets: {}, + isPreconfigured: true, + isDeprecated: false, + }, + ], + }; + }); + + const executor: ExecutorType<{}, {}, {}, void> = async (options) => { + return { status: 'ok', actionId: options.actionId }; }; -}); -const executor: ExecutorType<{}, {}, {}, void> = async (options) => { - return { status: 'ok', actionId: options.actionId }; -}; - -describe('register()', () => { - test('able to register action types', () => { - const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - actionTypeRegistry.register({ - id: 'my-action-type', - name: 'My action type', - minimumLicenseRequired: 'gold', - supportedFeatureIds: ['alerting'], - executor, - }); - expect(actionTypeRegistry.has('my-action-type')).toEqual(true); - expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); - expect(mockTaskManager.registerTaskDefinitions.mock.calls[0]).toMatchInlineSnapshot(` + describe('register()', () => { + test('able to register action types', () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'gold', + supportedFeatureIds: ['alerting'], + executor, + }); + expect(actionTypeRegistry.has('my-action-type')).toEqual(true); + expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); + expect(mockTaskManager.registerTaskDefinitions.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { "actions:my-action-type": Object { @@ -76,157 +77,157 @@ describe('register()', () => { }, ] `); - expect(actionTypeRegistryParams.licensing.featureUsage.register).toHaveBeenCalledWith( - 'Connector: My action type', - 'gold' - ); - }); - - test('shallow clones the given action type', () => { - const myType: ActionType = { - id: 'my-action-type', - name: 'My action type', - minimumLicenseRequired: 'basic', - supportedFeatureIds: ['alerting'], - executor, - }; - const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - actionTypeRegistry.register(myType); - myType.name = 'Changed'; - expect(actionTypeRegistry.get('my-action-type').name).toEqual('My action type'); - }); + expect(actionTypeRegistryParams.licensing.featureUsage.register).toHaveBeenCalledWith( + 'Connector: My action type', + 'gold' + ); + }); - test('throws error if action type already registered', () => { - const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - actionTypeRegistry.register({ - id: 'my-action-type', - name: 'My action type', - minimumLicenseRequired: 'basic', - supportedFeatureIds: ['alerting'], - executor, + test('shallow clones the given action type', () => { + const myType: ActionType = { + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + executor, + }; + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register(myType); + myType.name = 'Changed'; + expect(actionTypeRegistry.get('my-action-type').name).toEqual('My action type'); }); - expect(() => + + test('throws error if action type already registered', () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', minimumLicenseRequired: 'basic', supportedFeatureIds: ['alerting'], executor, - }) - ).toThrowErrorMatchingInlineSnapshot( - `"Action type \\"my-action-type\\" is already registered."` - ); - }); + }); + expect(() => + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + executor, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Action type \\"my-action-type\\" is already registered."` + ); + }); + + test('throws if empty supported feature ids provided', () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + expect(() => + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'basic', + supportedFeatureIds: [], + executor, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"At least one \\"supportedFeatureId\\" value must be supplied for connector type \\"my-action-type\\"."` + ); + }); - test('throws if empty supported feature ids provided', () => { - const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - expect(() => + test('throws if invalid feature ids provided', () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + expect(() => + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['foo'], + executor, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid feature ids \\"foo\\" for connector type \\"my-action-type\\"."` + ); + }); + + test('provides a getRetry function that handles ExecutorError', () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', minimumLicenseRequired: 'basic', - supportedFeatureIds: [], + supportedFeatureIds: ['alerting'], executor, - }) - ).toThrowErrorMatchingInlineSnapshot( - `"At least one \\"supportedFeatureId\\" value must be supplied for connector type \\"my-action-type\\"."` - ); - }); + }); + expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); + const registerTaskDefinitionsCall = mockTaskManager.registerTaskDefinitions.mock.calls[0][0]; + const getRetry = registerTaskDefinitionsCall['actions:my-action-type'].getRetry!; + + const retryTime = new Date(); + expect(getRetry(0, new Error())).toEqual(true); + expect(getRetry(0, new ExecutorError('my message', {}, true))).toEqual(true); + expect(getRetry(0, new ExecutorError('my message', {}, false))).toEqual(false); + expect(getRetry(0, new ExecutorError('my message', {}, undefined))).toEqual(false); + expect(getRetry(0, new ExecutorError('my message', {}, retryTime))).toEqual(retryTime); + }); - test('throws if invalid feature ids provided', () => { - const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - expect(() => + test('provides a getRetry function that handles errors based on maxAttempts', () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', minimumLicenseRequired: 'basic', - supportedFeatureIds: ['foo'], + supportedFeatureIds: ['alerting'], executor, - }) - ).toThrowErrorMatchingInlineSnapshot( - `"Invalid feature ids \\"foo\\" for connector type \\"my-action-type\\"."` - ); - }); - - test('provides a getRetry function that handles ExecutorError', () => { - const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - actionTypeRegistry.register({ - id: 'my-action-type', - name: 'My action type', - minimumLicenseRequired: 'basic', - supportedFeatureIds: ['alerting'], - executor, - }); - expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); - const registerTaskDefinitionsCall = mockTaskManager.registerTaskDefinitions.mock.calls[0][0]; - const getRetry = registerTaskDefinitionsCall['actions:my-action-type'].getRetry!; - - const retryTime = new Date(); - expect(getRetry(0, new Error())).toEqual(false); - expect(getRetry(0, new ExecutorError('my message', {}, true))).toEqual(true); - expect(getRetry(0, new ExecutorError('my message', {}, false))).toEqual(false); - expect(getRetry(0, new ExecutorError('my message', {}, undefined))).toEqual(false); - expect(getRetry(0, new ExecutorError('my message', {}, retryTime))).toEqual(retryTime); - }); - - test('provides a getRetry function that handles errors based on maxAttempts', () => { - const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - actionTypeRegistry.register({ - id: 'my-action-type', - name: 'My action type', - minimumLicenseRequired: 'basic', - supportedFeatureIds: ['alerting'], - executor, - maxAttempts: 2, + maxAttempts: 2, + }); + expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); + const registerTaskDefinitionsCall = mockTaskManager.registerTaskDefinitions.mock.calls[0][0]; + const getRetry = registerTaskDefinitionsCall['actions:my-action-type'].getRetry!; + + expect(getRetry(1, new Error())).toEqual(true); + expect(getRetry(3, new Error())).toEqual(false); }); - expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); - const registerTaskDefinitionsCall = mockTaskManager.registerTaskDefinitions.mock.calls[0][0]; - const getRetry = registerTaskDefinitionsCall['actions:my-action-type'].getRetry!; - expect(getRetry(1, new Error())).toEqual(true); - expect(getRetry(2, new Error())).toEqual(false); - }); - - test('registers gold+ action types to the licensing feature usage API', () => { - const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - actionTypeRegistry.register({ - id: 'my-action-type', - name: 'My action type', - minimumLicenseRequired: 'gold', - supportedFeatureIds: ['alerting'], - executor, + test('registers gold+ action types to the licensing feature usage API', () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'gold', + supportedFeatureIds: ['alerting'], + executor, + }); + expect(actionTypeRegistryParams.licensing.featureUsage.register).toHaveBeenCalledWith( + 'Connector: My action type', + 'gold' + ); }); - expect(actionTypeRegistryParams.licensing.featureUsage.register).toHaveBeenCalledWith( - 'Connector: My action type', - 'gold' - ); - }); - test(`doesn't register basic action types to the licensing feature usage API`, () => { - const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - actionTypeRegistry.register({ - id: 'my-action-type', - name: 'My action type', - minimumLicenseRequired: 'basic', - supportedFeatureIds: ['alerting'], - executor, + test(`doesn't register basic action types to the licensing feature usage API`, () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + executor, + }); + expect(actionTypeRegistryParams.licensing.featureUsage.register).not.toHaveBeenCalled(); }); - expect(actionTypeRegistryParams.licensing.featureUsage.register).not.toHaveBeenCalled(); }); -}); -describe('get()', () => { - test('returns action type', () => { - const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - actionTypeRegistry.register({ - id: 'my-action-type', - name: 'My action type', - minimumLicenseRequired: 'basic', - supportedFeatureIds: ['alerting'], - executor, - }); - const actionType = actionTypeRegistry.get('my-action-type'); - expect(actionType).toMatchInlineSnapshot(` + describe('get()', () => { + test('returns action type', () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + executor, + }); + const actionType = actionTypeRegistry.get('my-action-type'); + expect(actionType).toMatchInlineSnapshot(` Object { "executor": [Function], "id": "my-action-type", @@ -237,255 +238,213 @@ describe('get()', () => { ], } `); - }); + }); - test(`throws an error when action type doesn't exist`, () => { - const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - expect(() => actionTypeRegistry.get('my-action-type')).toThrowErrorMatchingInlineSnapshot( - `"Action type \\"my-action-type\\" is not registered."` - ); + test(`throws an error when action type doesn't exist`, () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + expect(() => actionTypeRegistry.get('my-action-type')).toThrowErrorMatchingInlineSnapshot( + `"Action type \\"my-action-type\\" is not registered."` + ); + }); }); -}); -describe('list()', () => { - test('returns list of action types', () => { - mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); - const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - actionTypeRegistry.register({ - id: 'my-action-type', - name: 'My action type', - minimumLicenseRequired: 'basic', - supportedFeatureIds: ['alerting'], - executor, + describe('list()', () => { + test('returns list of action types', () => { + mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + executor, + }); + const actionTypes = actionTypeRegistry.list(); + expect(actionTypes).toEqual([ + { + id: 'my-action-type', + name: 'My action type', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + }, + ]); + expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalled(); + expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalled(); }); - const actionTypes = actionTypeRegistry.list(); - expect(actionTypes).toEqual([ - { + + test('returns list of connector types filtered by feature id if provided', () => { + mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, minimumLicenseRequired: 'basic', supportedFeatureIds: ['alerting'], - }, - ]); - expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalled(); - expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalled(); + executor, + }); + actionTypeRegistry.register({ + id: 'another-action-type', + name: 'My action type', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['cases'], + executor, + }); + const actionTypes = actionTypeRegistry.list('alerting'); + expect(actionTypes).toEqual([ + { + id: 'my-action-type', + name: 'My action type', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + }, + ]); + expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalled(); + expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalled(); + }); }); - test('returns list of connector types filtered by feature id if provided', () => { - mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); - const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - actionTypeRegistry.register({ - id: 'my-action-type', - name: 'My action type', - minimumLicenseRequired: 'basic', - supportedFeatureIds: ['alerting'], - executor, + describe('has()', () => { + test('returns false for unregistered action types', () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + expect(actionTypeRegistry.has('my-action-type')).toEqual(false); }); - actionTypeRegistry.register({ - id: 'another-action-type', - name: 'My action type', - minimumLicenseRequired: 'basic', - supportedFeatureIds: ['cases'], - executor, - }); - const actionTypes = actionTypeRegistry.list('alerting'); - expect(actionTypes).toEqual([ - { + + test('returns true after registering an action type', () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, minimumLicenseRequired: 'basic', supportedFeatureIds: ['alerting'], - }, - ]); - expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalled(); - expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalled(); - }); -}); - -describe('has()', () => { - test('returns false for unregistered action types', () => { - const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - expect(actionTypeRegistry.has('my-action-type')).toEqual(false); + executor, + }); + expect(actionTypeRegistry.has('my-action-type')); + }); }); - test('returns true after registering an action type', () => { - const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - actionTypeRegistry.register({ - id: 'my-action-type', - name: 'My action type', + describe('isActionTypeEnabled', () => { + let actionTypeRegistry: ActionTypeRegistry; + const fooActionType: ActionType = { + id: 'foo', + name: 'Foo', minimumLicenseRequired: 'basic', supportedFeatureIds: ['alerting'], - executor, - }); - expect(actionTypeRegistry.has('my-action-type')); - }); -}); - -describe('isActionTypeEnabled', () => { - let actionTypeRegistry: ActionTypeRegistry; - const fooActionType: ActionType = { - id: 'foo', - name: 'Foo', - minimumLicenseRequired: 'basic', - supportedFeatureIds: ['alerting'], - executor: async (options) => { - return { status: 'ok', actionId: options.actionId }; - }, - }; - - beforeEach(() => { - actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - actionTypeRegistry.register(fooActionType); - }); + executor: async (options) => { + return { status: 'ok', actionId: options.actionId }; + }, + }; - test('should call isActionTypeEnabled of the actions config', async () => { - mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); - actionTypeRegistry.isActionTypeEnabled('foo'); - expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalledWith('foo'); - }); + beforeEach(() => { + actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register(fooActionType); + }); - test('should call isActionExecutable of the actions config', async () => { - mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); - actionTypeRegistry.isActionExecutable('my-slack1', 'foo'); - expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalledWith('foo'); - }); + test('should call isActionTypeEnabled of the actions config', async () => { + mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); + actionTypeRegistry.isActionTypeEnabled('foo'); + expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalledWith('foo'); + }); - test('should return true when isActionTypeEnabled is false and isLicenseValidForActionType is true and it has preconfigured connectors', async () => { - mockedActionsConfig.isActionTypeEnabled.mockReturnValue(false); - mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); + test('should call isActionExecutable of the actions config', async () => { + mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); + actionTypeRegistry.isActionExecutable('my-slack1', 'foo'); + expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalledWith('foo'); + }); - expect(actionTypeRegistry.isActionExecutable('my-slack1', 'foo')).toEqual(true); - }); + test('should return true when isActionTypeEnabled is false and isLicenseValidForActionType is true and it has preconfigured connectors', async () => { + mockedActionsConfig.isActionTypeEnabled.mockReturnValue(false); + mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); - test('should call isLicenseValidForActionType of the license state with notifyUsage false by default', async () => { - mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); - actionTypeRegistry.isActionTypeEnabled('foo'); - expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType, { - notifyUsage: false, + expect(actionTypeRegistry.isActionExecutable('my-slack1', 'foo')).toEqual(true); }); - }); - test('should call isLicenseValidForActionType of the license state with notifyUsage true when specified', async () => { - mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); - actionTypeRegistry.isActionTypeEnabled('foo', { notifyUsage: true }); - expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType, { - notifyUsage: true, + test('should call isLicenseValidForActionType of the license state with notifyUsage false by default', async () => { + mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); + actionTypeRegistry.isActionTypeEnabled('foo'); + expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType, { + notifyUsage: false, + }); }); - }); - - test('should return false when isActionTypeEnabled is false and isLicenseValidForActionType is true', async () => { - mockedActionsConfig.isActionTypeEnabled.mockReturnValue(false); - mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); - expect(actionTypeRegistry.isActionTypeEnabled('foo')).toEqual(false); - }); - test('should return false when isActionTypeEnabled is true and isLicenseValidForActionType is false', async () => { - mockedActionsConfig.isActionTypeEnabled.mockReturnValue(true); - mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ - isValid: false, - reason: 'invalid', + test('should call isLicenseValidForActionType of the license state with notifyUsage true when specified', async () => { + mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); + actionTypeRegistry.isActionTypeEnabled('foo', { notifyUsage: true }); + expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType, { + notifyUsage: true, + }); }); - expect(actionTypeRegistry.isActionTypeEnabled('foo')).toEqual(false); - }); -}); -describe('ensureActionTypeEnabled', () => { - let actionTypeRegistry: ActionTypeRegistry; - const fooActionType: ActionType = { - id: 'foo', - name: 'Foo', - minimumLicenseRequired: 'basic', - supportedFeatureIds: ['alerting'], - executor: async (options) => { - return { status: 'ok', actionId: options.actionId }; - }, - }; - - beforeEach(() => { - actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - actionTypeRegistry.register(fooActionType); - }); + test('should return false when isActionTypeEnabled is false and isLicenseValidForActionType is true', async () => { + mockedActionsConfig.isActionTypeEnabled.mockReturnValue(false); + mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); + expect(actionTypeRegistry.isActionTypeEnabled('foo')).toEqual(false); + }); - test('should call ensureActionTypeEnabled of the action config', async () => { - actionTypeRegistry.ensureActionTypeEnabled('foo'); - expect(mockedActionsConfig.ensureActionTypeEnabled).toHaveBeenCalledWith('foo'); + test('should return false when isActionTypeEnabled is true and isLicenseValidForActionType is false', async () => { + mockedActionsConfig.isActionTypeEnabled.mockReturnValue(true); + mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ + isValid: false, + reason: 'invalid', + }); + expect(actionTypeRegistry.isActionTypeEnabled('foo')).toEqual(false); + }); }); - test('should call ensureLicenseForActionType on the license state', async () => { - actionTypeRegistry.ensureActionTypeEnabled('foo'); - expect(mockedLicenseState.ensureLicenseForActionType).toHaveBeenCalledWith(fooActionType); - }); + describe('ensureActionTypeEnabled', () => { + let actionTypeRegistry: ActionTypeRegistry; + const fooActionType: ActionType = { + id: 'foo', + name: 'Foo', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + executor: async (options) => { + return { status: 'ok', actionId: options.actionId }; + }, + }; - test('should throw when ensureActionTypeEnabled throws', async () => { - mockedActionsConfig.ensureActionTypeEnabled.mockImplementation(() => { - throw new Error('Fail'); + beforeEach(() => { + actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register(fooActionType); }); - expect(() => - actionTypeRegistry.ensureActionTypeEnabled('foo') - ).toThrowErrorMatchingInlineSnapshot(`"Fail"`); - }); - test('should throw when ensureLicenseForActionType throws', async () => { - mockedLicenseState.ensureLicenseForActionType.mockImplementation(() => { - throw new Error('Fail'); + test('should call ensureActionTypeEnabled of the action config', async () => { + actionTypeRegistry.ensureActionTypeEnabled('foo'); + expect(mockedActionsConfig.ensureActionTypeEnabled).toHaveBeenCalledWith('foo'); }); - expect(() => - actionTypeRegistry.ensureActionTypeEnabled('foo') - ).toThrowErrorMatchingInlineSnapshot(`"Fail"`); - }); -}); - -describe('isActionExecutable()', () => { - let actionTypeRegistry: ActionTypeRegistry; - const fooActionType: ActionType = { - id: 'foo', - name: 'Foo', - minimumLicenseRequired: 'basic', - supportedFeatureIds: ['alerting'], - executor: async (options) => { - return { status: 'ok', actionId: options.actionId }; - }, - }; - - beforeEach(() => { - actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); - actionTypeRegistry.register(fooActionType); - }); - test('should call isLicenseValidForActionType of the license state with notifyUsage false by default', async () => { - mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); - actionTypeRegistry.isActionExecutable('123', 'foo'); - expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType, { - notifyUsage: false, + test('should call ensureLicenseForActionType on the license state', async () => { + actionTypeRegistry.ensureActionTypeEnabled('foo'); + expect(mockedLicenseState.ensureLicenseForActionType).toHaveBeenCalledWith(fooActionType); }); - }); - test('should call isLicenseValidForActionType of the license state with notifyUsage true when specified', async () => { - mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); - actionTypeRegistry.isActionExecutable('123', 'foo', { notifyUsage: true }); - expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType, { - notifyUsage: true, + test('should throw when ensureActionTypeEnabled throws', async () => { + mockedActionsConfig.ensureActionTypeEnabled.mockImplementation(() => { + throw new Error('Fail'); + }); + expect(() => + actionTypeRegistry.ensureActionTypeEnabled('foo') + ).toThrowErrorMatchingInlineSnapshot(`"Fail"`); }); - }); -}); -describe('getAllTypes()', () => { - test('should return empty when notihing is registered', () => { - const registry = new ActionTypeRegistry(actionTypeRegistryParams); - const result = registry.getAllTypes(); - expect(result).toEqual([]); + test('should throw when ensureLicenseForActionType throws', async () => { + mockedLicenseState.ensureLicenseForActionType.mockImplementation(() => { + throw new Error('Fail'); + }); + expect(() => + actionTypeRegistry.ensureActionTypeEnabled('foo') + ).toThrowErrorMatchingInlineSnapshot(`"Fail"`); + }); }); - test('should return list of registered type ids', () => { - mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); - const registry = new ActionTypeRegistry(actionTypeRegistryParams); - registry.register({ + describe('isActionExecutable()', () => { + let actionTypeRegistry: ActionTypeRegistry; + const fooActionType: ActionType = { id: 'foo', name: 'Foo', minimumLicenseRequired: 'basic', @@ -493,8 +452,51 @@ describe('getAllTypes()', () => { executor: async (options) => { return { status: 'ok', actionId: options.actionId }; }, + }; + + beforeEach(() => { + actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register(fooActionType); + }); + + test('should call isLicenseValidForActionType of the license state with notifyUsage false by default', async () => { + mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); + actionTypeRegistry.isActionExecutable('123', 'foo'); + expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType, { + notifyUsage: false, + }); + }); + + test('should call isLicenseValidForActionType of the license state with notifyUsage true when specified', async () => { + mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); + actionTypeRegistry.isActionExecutable('123', 'foo', { notifyUsage: true }); + expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType, { + notifyUsage: true, + }); + }); + }); + + describe('getAllTypes()', () => { + test('should return empty when notihing is registered', () => { + const registry = new ActionTypeRegistry(actionTypeRegistryParams); + const result = registry.getAllTypes(); + expect(result).toEqual([]); + }); + + test('should return list of registered type ids', () => { + mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); + const registry = new ActionTypeRegistry(actionTypeRegistryParams); + registry.register({ + id: 'foo', + name: 'Foo', + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + executor: async (options) => { + return { status: 'ok', actionId: options.actionId }; + }, + }); + const result = registry.getAllTypes(); + expect(result).toEqual(['foo']); }); - const result = registry.getAllTypes(); - expect(result).toEqual(['foo']); }); }); diff --git a/x-pack/plugins/actions/server/action_type_registry.ts b/x-pack/plugins/actions/server/action_type_registry.ts index 54fcfa69f403f3..31203a4c7efab4 100644 --- a/x-pack/plugins/actions/server/action_type_registry.ts +++ b/x-pack/plugins/actions/server/action_type_registry.ts @@ -25,8 +25,6 @@ import { ActionTypeParams, } from './types'; -export const MAX_ATTEMPTS: number = 3; - export interface ActionTypeRegistryOpts { licensing: LicensingPluginSetup; taskManager: TaskManagerSetupContract; @@ -149,20 +147,25 @@ export class ActionTypeRegistry { ); } + const maxAttempts = this.actionsConfigUtils.getMaxAttempts({ + actionTypeId: actionType.id, + actionTypeMaxAttempts: actionType.maxAttempts, + }); + this.actionTypes.set(actionType.id, { ...actionType } as unknown as ActionType); this.taskManager.registerTaskDefinitions({ [`actions:${actionType.id}`]: { title: actionType.name, - maxAttempts: actionType.maxAttempts || MAX_ATTEMPTS, + maxAttempts, getRetry(attempts: number, error: unknown) { if (error instanceof ExecutorError) { return error.retry == null ? false : error.retry; } // Only retry other kinds of errors based on attempts - return attempts < (actionType.maxAttempts ?? 0); + return attempts < maxAttempts; }, createTaskRunner: (context: RunContext) => - this.taskRunnerFactory.create(context, actionType.maxAttempts), + this.taskRunnerFactory.create(context, maxAttempts), }, }); // No need to notify usage on basic action types diff --git a/x-pack/plugins/actions/server/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index bf0ebb4e4791d7..26a626cd88bf41 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -26,6 +26,7 @@ const createActionsConfigMock = () => { getCustomHostSettings: jest.fn().mockReturnValue(undefined), getMicrosoftGraphApiUrl: jest.fn().mockReturnValue(undefined), validateEmailAddresses: jest.fn().mockReturnValue(undefined), + getMaxAttempts: jest.fn().mockReturnValue(3), }; 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 b1af4a843b496c..fe0c913543e6a5 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -534,3 +534,38 @@ describe('validateEmailAddresses()', () => { ); }); }); + +describe('getMaxAttempts()', () => { + test('returns the maxAttempts defined in config', () => { + const acu = getActionsConfigurationUtilities({ + ...defaultActionsConfig, + run: { maxAttempts: 1 }, + }); + const maxAttempts = acu.getMaxAttempts({ actionTypeMaxAttempts: 2, actionTypeId: 'slack' }); + expect(maxAttempts).toEqual(1); + }); + + test('returns the maxAttempts defined in config for the action type', () => { + const acu = getActionsConfigurationUtilities({ + ...defaultActionsConfig, + run: { maxAttempts: 1, connectorTypeOverrides: [{ id: 'slack', maxAttempts: 4 }] }, + }); + const maxAttempts = acu.getMaxAttempts({ actionTypeMaxAttempts: 2, actionTypeId: 'slack' }); + expect(maxAttempts).toEqual(4); + }); + + test('returns the maxAttempts passed by the action type', () => { + const acu = getActionsConfigurationUtilities(defaultActionsConfig); + const maxAttempts = acu.getMaxAttempts({ actionTypeMaxAttempts: 2, actionTypeId: 'slack' }); + expect(maxAttempts).toEqual(2); + }); + + test('returns the default maxAttempts', () => { + const acu = getActionsConfigurationUtilities(defaultActionsConfig); + const maxAttempts = acu.getMaxAttempts({ + actionTypeMaxAttempts: undefined, + actionTypeId: 'slack', + }); + expect(maxAttempts).toEqual(3); + }); +}); diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index 611cf4a394ad78..43dd35ba38021c 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -28,6 +28,8 @@ enum AllowListingField { hostname = 'hostname', } +export const DEFAULT_MAX_ATTEMPTS: number = 3; + export interface ActionsConfigurationUtilities { isHostnameAllowed: (hostname: string) => boolean; isUriAllowed: (uri: string) => boolean; @@ -40,6 +42,13 @@ export interface ActionsConfigurationUtilities { getResponseSettings: () => ResponseSettings; getCustomHostSettings: (targetUrl: string) => CustomHostSettings | undefined; getMicrosoftGraphApiUrl: () => undefined | string; + getMaxAttempts: ({ + actionTypeMaxAttempts, + actionTypeId, + }: { + actionTypeMaxAttempts?: number; + actionTypeId: string; + }) => number; validateEmailAddresses( addresses: string[], options?: ValidateEmailAddressesOptions @@ -194,5 +203,17 @@ export function getActionsConfigurationUtilities( getMicrosoftGraphApiUrl: () => getMicrosoftGraphApiUrlFromConfig(config), validateEmailAddresses: (addresses: string[], options: ValidateEmailAddressesOptions) => validatedEmailCurried(addresses, options), + getMaxAttempts: ({ actionTypeMaxAttempts, actionTypeId }) => { + const connectorTypeConfig = config.run?.connectorTypeOverrides?.find( + (connectorType) => actionTypeId === connectorType.id + ); + + return ( + connectorTypeConfig?.maxAttempts || + config.run?.maxAttempts || + actionTypeMaxAttempts || + DEFAULT_MAX_ATTEMPTS + ); + }, }; } diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index 76270a466ee8fa..05ac2ea85a47ed 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -16,6 +16,9 @@ export enum EnabledActionTypes { Any = '*', } +const MAX_MAX_ATTEMPTS = 10; +const MIN_MAX_ATTEMPTS = 1; + const preconfiguredActionSchema = schema.object({ name: schema.string({ minLength: 1 }), actionTypeId: schema.string({ minLength: 1 }), @@ -56,6 +59,11 @@ const customHostSettingsSchema = schema.object({ export type CustomHostSettings = TypeOf; +const connectorTypeSchema = schema.object({ + id: schema.string(), + maxAttempts: schema.maybe(schema.number({ min: MIN_MAX_ATTEMPTS, max: MAX_MAX_ATTEMPTS })), +}); + export const configSchema = schema.object({ allowedHosts: schema.arrayOf( schema.oneOf([schema.string({ hostname: true }), schema.literal(AllowedHosts.Any)]), @@ -117,6 +125,12 @@ export const configSchema = schema.object({ domain_allowlist: schema.arrayOf(schema.string()), }) ), + run: schema.maybe( + schema.object({ + maxAttempts: schema.maybe(schema.number({ min: MIN_MAX_ATTEMPTS, max: MAX_MAX_ATTEMPTS })), + connectorTypeOverrides: schema.maybe(schema.arrayOf(connectorTypeSchema)), + }) + ), }); export type ActionsConfig = TypeOf; diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.ts index 2c23dbc77e3162..8ef48089db49db 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.ts @@ -105,7 +105,7 @@ export class TaskRunnerFactory { // Throwing an executor error means we will attempt to retry the task // TM will treat a task as a failure if `attempts >= maxAttempts` // so we need to handle that here to avoid TM persisting the failed task - const isRetryableBasedOnAttempts = taskInfo.attempts < (maxAttempts ?? 1); + const isRetryableBasedOnAttempts = taskInfo.attempts < maxAttempts; const willRetryMessage = `and will retry`; const willNotRetryMessage = `and will not retry`; diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 178ceb8f2b95e4..d45fde5cff09f0 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -43,7 +43,7 @@ import { import { ActionsConfig, getValidatedConfig } from './config'; import { resolveCustomHosts } from './lib/custom_host_settings'; import { ActionsClient } from './actions_client'; -import { ActionTypeRegistry, MAX_ATTEMPTS } from './action_type_registry'; +import { ActionTypeRegistry } from './action_type_registry'; import { createExecutionEnqueuerFunction, createEphemeralExecutionEnqueuerFunction, @@ -360,7 +360,6 @@ export class ActionsPlugin implements Plugin ) => { ensureSufficientLicense(actionType); - actionType.maxAttempts = actionType.maxAttempts ?? MAX_ATTEMPTS; actionTypeRegistry.register(actionType); }, registerSubActionConnectorType: <