Make action retries configurable (#147876)

Resolves: #146222

This PR makes maximum number of retries of an action configurable. 

Follows the same pattern we used in alerting plugin.
`xpack.actions.run.maxAttempts` as a global settings and
`xpack.actions.run.connectorTypeOverrides` to override the global
settings for specific connector types.
This commit is contained in:
Ersin Erdal 2022-12-23 15:54:16 +01:00 committed by GitHub
parent 3ac25e9b9d
commit ffb1dc3e28
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 506 additions and 415 deletions

View file

@ -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

View file

@ -21,50 +21,51 @@ let mockedLicenseState: jest.Mocked<ILicenseState>;
let mockedActionsConfig: jest.Mocked<ActionsConfigurationUtilities>;
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');
});
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,
expect(actionTypeRegistryParams.licensing.featureUsage.register).toHaveBeenCalledWith(
'Connector: My action type',
'gold'
);
});
expect(() =>
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');
});
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(() =>
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 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!;
test('throws if invalid feature ids provided', () => {
const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
expect(() =>
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('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\\"."`
);
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);
});
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'
);
});
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();
});
});
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,
});
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,
});
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,
});
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,99 @@ 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."`
);
});
});
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([
{
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',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
supportedFeatureIds: ['alerting'],
},
]);
expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalled();
expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalled();
});
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();
});
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,
});
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 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();
});
});
describe('has()', () => {
test('returns false for unregistered action types', () => {
const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
expect(actionTypeRegistry.has('my-action-type')).toEqual(false);
});
test('returns true after registering an action type', () => {
const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
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);
});
test('should call isActionTypeEnabled of the actions config', async () => {
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
actionTypeRegistry.isActionTypeEnabled('foo');
expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalledWith('foo');
});
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 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 });
expect(actionTypeRegistry.isActionExecutable('my-slack1', 'foo')).toEqual(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,
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('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,
describe('has()', () => {
test('returns false for unregistered action types', () => {
const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
expect(actionTypeRegistry.has('my-action-type')).toEqual(false);
});
test('returns true after registering an action type', () => {
const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
supportedFeatureIds: ['alerting'],
executor,
});
expect(actionTypeRegistry.has('my-action-type'));
});
});
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',
});
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 call ensureActionTypeEnabled of the action config', async () => {
actionTypeRegistry.ensureActionTypeEnabled('foo');
expect(mockedActionsConfig.ensureActionTypeEnabled).toHaveBeenCalledWith('foo');
});
test('should call ensureLicenseForActionType on the license state', async () => {
actionTypeRegistry.ensureActionTypeEnabled('foo');
expect(mockedLicenseState.ensureLicenseForActionType).toHaveBeenCalledWith(fooActionType);
});
test('should throw when ensureActionTypeEnabled throws', async () => {
mockedActionsConfig.ensureActionTypeEnabled.mockImplementation(() => {
throw new Error('Fail');
});
expect(() =>
actionTypeRegistry.ensureActionTypeEnabled('foo')
).toThrowErrorMatchingInlineSnapshot(`"Fail"`);
});
test('should throw when ensureLicenseForActionType throws', async () => {
mockedLicenseState.ensureLicenseForActionType.mockImplementation(() => {
throw new Error('Fail');
});
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 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({
describe('isActionTypeEnabled', () => {
let actionTypeRegistry: ActionTypeRegistry;
const fooActionType: ActionType = {
id: 'foo',
name: 'Foo',
minimumLicenseRequired: 'basic',
@ -493,8 +338,165 @@ describe('getAllTypes()', () => {
executor: async (options) => {
return { status: 'ok', actionId: options.actionId };
},
};
beforeEach(() => {
actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
actionTypeRegistry.register(fooActionType);
});
test('should call isActionTypeEnabled of the actions config', async () => {
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
actionTypeRegistry.isActionTypeEnabled('foo');
expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalledWith('foo');
});
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 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 });
expect(actionTypeRegistry.isActionExecutable('my-slack1', 'foo')).toEqual(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 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 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',
});
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 call ensureActionTypeEnabled of the action config', async () => {
actionTypeRegistry.ensureActionTypeEnabled('foo');
expect(mockedActionsConfig.ensureActionTypeEnabled).toHaveBeenCalledWith('foo');
});
test('should call ensureLicenseForActionType on the license state', async () => {
actionTypeRegistry.ensureActionTypeEnabled('foo');
expect(mockedLicenseState.ensureLicenseForActionType).toHaveBeenCalledWith(fooActionType);
});
test('should throw when ensureActionTypeEnabled throws', async () => {
mockedActionsConfig.ensureActionTypeEnabled.mockImplementation(() => {
throw new Error('Fail');
});
expect(() =>
actionTypeRegistry.ensureActionTypeEnabled('foo')
).toThrowErrorMatchingInlineSnapshot(`"Fail"`);
});
test('should throw when ensureLicenseForActionType throws', async () => {
mockedLicenseState.ensureLicenseForActionType.mockImplementation(() => {
throw new Error('Fail');
});
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 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']);
});
});

View file

@ -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

View file

@ -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;
};

View file

@ -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);
});
});

View file

@ -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
);
},
};
}

View file

@ -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<typeof customHostSettingsSchema>;
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<typeof configSchema>;

View file

@ -106,7 +106,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`;

View file

@ -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<PluginSetupContract, PluginStartCon
actionType: ActionType<Config, Secrets, Params, ExecutorResultData>
) => {
ensureSufficientLicense(actionType);
actionType.maxAttempts = actionType.maxAttempts ?? MAX_ATTEMPTS;
actionTypeRegistry.register(actionType);
},
registerSubActionConnectorType: <