diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index f690e1b603359b..26a7a94401e0a5 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -41,6 +41,7 @@ export enum RuleExecutionStatusErrorReasons { License = 'license', Timeout = 'timeout', Disabled = 'disabled', + Validate = 'validate', } export enum RuleExecutionStatusWarningReasons { diff --git a/x-pack/plugins/alerting/server/task_runner/rule_loader.test.ts b/x-pack/plugins/alerting/server/task_runner/rule_loader.test.ts new file mode 100644 index 00000000000000..1fb7e3c8576aaf --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/rule_loader.test.ts @@ -0,0 +1,363 @@ +/* + * 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 { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { KibanaRequest } from '@kbn/core/server'; +import { schema } from '@kbn/config-schema'; +import type { PublicMethodsOf } from '@kbn/utility-types'; + +import { getDecryptedAttributes, getFakeKibanaRequest, loadRule } from './rule_loader'; +import { TaskRunnerContext } from './task_runner_factory'; +import { ruleTypeRegistryMock } from '../rule_type_registry.mock'; +import { rulesClientMock } from '../rules_client.mock'; +import { RulesClient } from '../rules_client'; +import { Rule } from '../types'; +import { MONITORING_HISTORY_LIMIT, RuleExecutionStatusErrorReasons } from '../../common'; +import { getReasonFromError } from '../lib/error_with_reason'; +import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; + +// create mocks +const rulesClient = rulesClientMock.create(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const alertingEventLogger = alertingEventLoggerMock.create(); +const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const mockBasePathService = { set: jest.fn() }; + +// assign default parameters/data +const apiKey = 'rule-apikey'; +const ruleId = 'rule-id-1'; +const enabled = true; +const spaceId = 'rule-spaceId'; +const ruleName = 'rule-name'; +const consumer = 'rule-consumer'; +const ruleTypeId = 'rule-type-id'; +const ruleParams = { paramA: 42 }; + +describe('rule_loader', () => { + let context: TaskRunnerContext; + let contextMock: ReturnType; + + const paramValidator = schema.object({ + paramA: schema.number(), + }); + + const DefaultLoadRuleParams = { + paramValidator, + ruleId, + spaceId, + ruleTypeRegistry, + alertingEventLogger, + }; + + beforeEach(() => { + jest.resetAllMocks(); + encryptedSavedObjects.getDecryptedAsInternalUser.mockImplementation( + mockGetDecrypted({ + apiKey, + enabled, + consumer, + }) + ); + contextMock = getTaskRunnerContext(ruleParams, MONITORING_HISTORY_LIMIT); + context = contextMock as unknown as TaskRunnerContext; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('loadRule()', () => { + describe('succeeds', () => { + test('with API key, a full execution history, and validator', async () => { + const result = await loadRule({ ...DefaultLoadRuleParams, context }); + + expect(result.apiKey).toBe(apiKey); + expect(result.validatedParams).toEqual(ruleParams); + expect(result.fakeRequest.headers.authorization).toEqual(`ApiKey ${apiKey}`); + expect(result.rule.alertTypeId).toBe(ruleTypeId); + expect(result.rule.name).toBe(ruleName); + expect(result.rule.params).toBe(ruleParams); + expect(result.rule.monitoring?.execution.history.length).toBe(MONITORING_HISTORY_LIMIT - 1); + }); + + test('without API key, any execution history, or validator', async () => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockImplementation( + mockGetDecrypted({ enabled, consumer }) + ); + + contextMock = getTaskRunnerContext(ruleParams, 0); + context = contextMock as unknown as TaskRunnerContext; + + const result = await loadRule({ + ...DefaultLoadRuleParams, + context, + paramValidator: undefined, + }); + + expect(result.apiKey).toBe(undefined); + expect(result.validatedParams).toEqual(ruleParams); + expect(result.fakeRequest.headers.authorization).toBe(undefined); + expect(result.rule.alertTypeId).toBe(ruleTypeId); + expect(result.rule.name).toBe(ruleName); + expect(result.rule.params).toBe(ruleParams); + expect(result.rule.monitoring?.execution.history.length).toBe(0); + }); + }); + + test('throws when cannot decrypt attributes', async () => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockImplementation(() => { + throw new Error('eso-error: 42'); + }); + + let outcome = 'success'; + try { + await loadRule({ ...DefaultLoadRuleParams, context }); + } catch (err) { + outcome = 'failure'; + expect(err.message).toBe('eso-error: 42'); + expect(getReasonFromError(err)).toBe(RuleExecutionStatusErrorReasons.Decrypt); + } + expect(outcome).toBe('failure'); + }); + + test('throws when rule is not enabled', async () => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockImplementation( + mockGetDecrypted({ apiKey, enabled: false, consumer }) + ); + + let outcome = 'success'; + try { + await loadRule({ ...DefaultLoadRuleParams, context }); + } catch (err) { + outcome = 'failure'; + expect(getReasonFromError(err)).toBe(RuleExecutionStatusErrorReasons.Disabled); + } + expect(outcome).toBe('failure'); + }); + + test('throws when user cannot read rule', async () => { + context.getRulesClientWithRequest = function ( + fakeRequest: unknown + ): PublicMethodsOf { + rulesClient.get.mockImplementation(async (args: unknown) => { + throw new Error('rule-client-error: 1001'); + }); + return rulesClient; + }; + + let outcome = 'success'; + try { + await loadRule({ ...DefaultLoadRuleParams, context }); + } catch (err) { + outcome = 'failure'; + expect(err.message).toBe('rule-client-error: 1001'); + expect(getReasonFromError(err)).toBe(RuleExecutionStatusErrorReasons.Read); + } + expect(outcome).toBe('failure'); + }); + + test('throws when rule type is not enabled', async () => { + ruleTypeRegistry.ensureRuleTypeEnabled.mockImplementation(() => { + throw new Error('rule-type-not-enabled: 2112'); + }); + + let outcome = 'success'; + try { + await loadRule({ ...DefaultLoadRuleParams, context }); + } catch (err) { + outcome = 'failure'; + expect(err.message).toBe('rule-type-not-enabled: 2112'); + expect(getReasonFromError(err)).toBe(RuleExecutionStatusErrorReasons.License); + } + expect(outcome).toBe('failure'); + }); + + test('throws when rule params fail validation', async () => { + const parameterValidator = schema.object({ + paramA: schema.string(), + }); + + let outcome = 'success'; + try { + await loadRule({ + ...DefaultLoadRuleParams, + context, + paramValidator: parameterValidator, + }); + } catch (err) { + outcome = 'failure'; + expect(err.message).toMatch('[paramA]: expected value of type [string] but got [number]'); + expect(getReasonFromError(err)).toBe(RuleExecutionStatusErrorReasons.Validate); + } + expect(outcome).toBe('failure'); + }); + }); + + describe('getDecryptedAttributes()', () => { + test('succeeds with default space', async () => { + contextMock.spaceIdToNamespace.mockReturnValue(undefined); + const result = await getDecryptedAttributes(context, ruleId, 'default'); + + expect(result.apiKey).toBe(apiKey); + expect(result.consumer).toBe(consumer); + expect(result.enabled).toBe(true); + expect(contextMock.spaceIdToNamespace.mock.calls[0]).toEqual(['default']); + + const esoArgs = encryptedSavedObjects.getDecryptedAsInternalUser.mock.calls[0]; + expect(esoArgs).toEqual(['alert', ruleId, { namespace: undefined }]); + }); + + test('succeeds with non-default space', async () => { + contextMock.spaceIdToNamespace.mockReturnValue(spaceId); + const result = await getDecryptedAttributes(context, ruleId, spaceId); + + expect(result.apiKey).toBe(apiKey); + expect(result.consumer).toBe(consumer); + expect(result.enabled).toBe(true); + expect(contextMock.spaceIdToNamespace.mock.calls[0]).toEqual([spaceId]); + + const esoArgs = encryptedSavedObjects.getDecryptedAsInternalUser.mock.calls[0]; + expect(esoArgs).toEqual(['alert', ruleId, { namespace: spaceId }]); + }); + + test('fails', async () => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockImplementation( + async (type, id, opts) => { + throw new Error('wops'); + } + ); + + const promise = getDecryptedAttributes(context, ruleId, spaceId); + await expect(promise).rejects.toThrow('wops'); + }); + }); + + describe('getFakeKibanaRequest()', () => { + test('has API key, in default space', async () => { + const kibanaRequestFromMock = jest.spyOn(KibanaRequest, 'from'); + const fakeRequest = getFakeKibanaRequest(context, 'default', apiKey); + + const bpsSetParams = mockBasePathService.set.mock.calls[0]; + expect(bpsSetParams).toEqual([fakeRequest, '/']); + expect(fakeRequest).toEqual(expect.any(KibanaRequest)); + expect(kibanaRequestFromMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "headers": Object { + "authorization": "ApiKey rule-apikey", + }, + "path": "/", + "raw": Object { + "req": Object { + "url": "/", + }, + }, + "route": Object { + "settings": Object {}, + }, + "url": Object { + "href": "/", + }, + }, + ] + `); + }); + + test('has API key, in non-default space', async () => { + const kibanaRequestFromMock = jest.spyOn(KibanaRequest, 'from'); + const fakeRequest = getFakeKibanaRequest(context, spaceId, apiKey); + + const bpsSetParams = mockBasePathService.set.mock.calls[0]; + expect(bpsSetParams).toEqual([fakeRequest, '/s/rule-spaceId']); + expect(fakeRequest).toEqual(expect.any(KibanaRequest)); + expect(kibanaRequestFromMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "headers": Object { + "authorization": "ApiKey rule-apikey", + }, + "path": "/", + "raw": Object { + "req": Object { + "url": "/", + }, + }, + "route": Object { + "settings": Object {}, + }, + "url": Object { + "href": "/", + }, + }, + ] + `); + }); + + test('does not have API key, in default space', async () => { + const kibanaRequestFromMock = jest.spyOn(KibanaRequest, 'from'); + const fakeRequest = getFakeKibanaRequest(context, 'default', null); + + const bpsSetParams = mockBasePathService.set.mock.calls[0]; + expect(bpsSetParams).toEqual([fakeRequest, '/']); + + expect(fakeRequest).toEqual(expect.any(KibanaRequest)); + expect(kibanaRequestFromMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "headers": Object {}, + "path": "/", + "raw": Object { + "req": Object { + "url": "/", + }, + }, + "route": Object { + "settings": Object {}, + }, + "url": Object { + "href": "/", + }, + }, + ] + `); + }); + }); +}); + +// returns a version of encryptedSavedObjects.getDecryptedAsInternalUser() with provided params +function mockGetDecrypted(attributes: { apiKey?: string; enabled: boolean; consumer: string }) { + return async (type: string, id: string, opts_: unknown) => { + return { id, type, references: [], attributes }; + }; +} + +// return enough of TaskRunnerContext that rule_loader needs +function getTaskRunnerContext(ruleParameters: unknown, historyElements: number) { + return { + spaceIdToNamespace: jest.fn(), + encryptedSavedObjectsClient: encryptedSavedObjects, + basePathService: mockBasePathService, + getRulesClientWithRequest, + }; + + function getRulesClientWithRequest(fakeRequest: unknown) { + // only need get() mocked + rulesClient.get.mockImplementation(async (args: unknown) => { + return { + name: ruleName, + alertTypeId: ruleTypeId, + params: ruleParameters, + monitoring: { + execution: { + history: new Array(historyElements), + }, + }, + } as Rule; + }); + return rulesClient; + } +} diff --git a/x-pack/plugins/alerting/server/task_runner/rule_loader.ts b/x-pack/plugins/alerting/server/task_runner/rule_loader.ts new file mode 100644 index 00000000000000..2019226c537fbd --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/rule_loader.ts @@ -0,0 +1,147 @@ +/* + * 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 { PublicMethodsOf } from '@kbn/utility-types'; +import type { Request } from '@hapi/hapi'; +import { addSpaceIdToPath } from '@kbn/spaces-plugin/server'; +import { KibanaRequest } from '@kbn/core/server'; +import { TaskRunnerContext } from './task_runner_factory'; +import { ErrorWithReason, validateRuleTypeParams } from '../lib'; +import { + RuleExecutionStatusErrorReasons, + RawRule, + RuleTypeRegistry, + RuleTypeParamsValidator, + SanitizedRule, +} from '../types'; +import { MONITORING_HISTORY_LIMIT, RuleTypeParams } from '../../common'; +import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; + +export interface LoadRuleParams { + paramValidator?: RuleTypeParamsValidator; + ruleId: string; + spaceId: string; + context: TaskRunnerContext; + ruleTypeRegistry: RuleTypeRegistry; + alertingEventLogger: PublicMethodsOf; +} + +export async function loadRule(params: LoadRuleParams) { + const { paramValidator, ruleId, spaceId, context, ruleTypeRegistry, alertingEventLogger } = + params; + let enabled: boolean; + let apiKey: string | null; + + try { + const decryptedAttributes = await getDecryptedAttributes(context, ruleId, spaceId); + apiKey = decryptedAttributes.apiKey; + enabled = decryptedAttributes.enabled; + } catch (err) { + throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Decrypt, err); + } + + if (!enabled) { + throw new ErrorWithReason( + RuleExecutionStatusErrorReasons.Disabled, + new Error(`Rule failed to execute because rule ran after it was disabled.`) + ); + } + + const fakeRequest = getFakeKibanaRequest(context, spaceId, apiKey); + const rulesClient = context.getRulesClientWithRequest(fakeRequest); + + let rule: SanitizedRule; + + // Ensure API key is still valid and user has access + try { + rule = await rulesClient.get({ id: ruleId }); + } catch (err) { + throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Read, err); + } + + alertingEventLogger.setRuleName(rule.name); + + try { + ruleTypeRegistry.ensureRuleTypeEnabled(rule.alertTypeId); + } catch (err) { + throw new ErrorWithReason(RuleExecutionStatusErrorReasons.License, err); + } + + let validatedParams: Params; + try { + validatedParams = validateRuleTypeParams(rule.params, paramValidator); + } catch (err) { + throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Validate, err); + } + + if (rule.monitoring) { + if (rule.monitoring.execution.history.length >= MONITORING_HISTORY_LIMIT) { + // Remove the first (oldest) record + rule.monitoring.execution.history.shift(); + } + } + + return { + rule, + fakeRequest, + apiKey, + rulesClient, + validatedParams, + }; +} + +export async function getDecryptedAttributes( + context: TaskRunnerContext, + ruleId: string, + spaceId: string +): Promise<{ apiKey: string | null; enabled: boolean; consumer: string }> { + const namespace = context.spaceIdToNamespace(spaceId); + + // Only fetch encrypted attributes here, we'll create a saved objects client + // scoped with the API key to fetch the remaining data. + const { + attributes: { apiKey, enabled, consumer }, + } = await context.encryptedSavedObjectsClient.getDecryptedAsInternalUser( + 'alert', + ruleId, + { namespace } + ); + + return { apiKey, enabled, consumer }; +} + +export function getFakeKibanaRequest( + context: TaskRunnerContext, + spaceId: string, + apiKey: RawRule['apiKey'] +) { + const requestHeaders: Record = {}; + + if (apiKey) { + requestHeaders.authorization = `ApiKey ${apiKey}`; + } + + const path = addSpaceIdToPath('/', spaceId); + + const fakeRequest = KibanaRequest.from({ + headers: requestHeaders, + path: '/', + route: { settings: {} }, + url: { + href: '/', + }, + raw: { + req: { + url: '/', + }, + }, + } as unknown as Request); + + context.basePathService.set(fakeRequest, path); + + return fakeRequest; +} diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index bd83b269ce10d5..8d6094930334a3 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -6,10 +6,8 @@ */ import apm from 'elastic-apm-node'; import { cloneDeep, mapValues, omit, pickBy, without } from 'lodash'; -import type { Request } from '@hapi/hapi'; import { UsageCounter } from '@kbn/usage-collection-plugin/server'; import uuid from 'uuid'; -import { addSpaceIdToPath } from '@kbn/spaces-plugin/server'; import { KibanaRequest, Logger } from '@kbn/core/server'; import { ConcreteTaskInstance, throwUnrecoverableError } from '@kbn/task-manager-plugin/server'; import { millisToNanos, nanosToMillis } from '@kbn/event-log-plugin/server'; @@ -23,7 +21,6 @@ import { executionStatusFromState, getRecoveredAlerts, ruleExecutionStatusToRaw, - validateRuleTypeParams, isRuleSnoozed, } from '../lib'; import { @@ -51,7 +48,6 @@ import { AlertInstanceState, RuleTypeParams, RuleTypeState, - MONITORING_HISTORY_LIMIT, parseDuration, WithoutReservedActionGroups, } from '../../common'; @@ -74,6 +70,7 @@ import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; import { wrapSearchSourceClient } from '../lib/wrap_search_source_client'; import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; import { SearchMetrics } from '../lib/types'; +import { loadRule, getDecryptedAttributes, getFakeKibanaRequest } from './rule_loader'; const FALLBACK_RETRY_INTERVAL = '5m'; const CONNECTIVITY_RETRY_INTERVAL = '5m'; @@ -145,52 +142,6 @@ export class TaskRunner< this.alertingEventLogger = new AlertingEventLogger(this.context.eventLogger); } - private async getDecryptedAttributes( - ruleId: string, - spaceId: string - ): Promise<{ apiKey: string | null; enabled: boolean; consumer: string }> { - const namespace = this.context.spaceIdToNamespace(spaceId); - // Only fetch encrypted attributes here, we'll create a saved objects client - // scoped with the API key to fetch the remaining data. - const { - attributes: { apiKey, enabled, consumer }, - } = await this.context.encryptedSavedObjectsClient.getDecryptedAsInternalUser( - 'alert', - ruleId, - { namespace } - ); - - return { apiKey, enabled, consumer }; - } - - private getFakeKibanaRequest(spaceId: string, apiKey: RawRule['apiKey']) { - const requestHeaders: Record = {}; - - if (apiKey) { - requestHeaders.authorization = `ApiKey ${apiKey}`; - } - - const path = addSpaceIdToPath('/', spaceId); - - const fakeRequest = KibanaRequest.from({ - headers: requestHeaders, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - } as unknown as Request); - - this.context.basePathService.set(fakeRequest, path); - - return fakeRequest; - } - private getExecutionHandler( ruleId: string, ruleName: string, @@ -567,17 +518,16 @@ export class TaskRunner< }; } - private async validateAndExecuteRule( + private async prepareAndExecuteRule( fakeRequest: KibanaRequest, apiKey: RawRule['apiKey'], - rule: SanitizedRule + rule: SanitizedRule, + validatedParams: Params ) { const { params: { alertId: ruleId, spaceId }, } = this.taskInstance; - // Validate - const validatedParams = validateRuleTypeParams(rule.params, this.ruleType.validate?.params); const executionHandler = this.getExecutionHandler( ruleId, rule.name, @@ -599,12 +549,12 @@ export class TaskRunner< params: { alertId: ruleId, spaceId }, } = this.taskInstance; try { - const decryptedAttributes = await this.getDecryptedAttributes(ruleId, spaceId); + const decryptedAttributes = await getDecryptedAttributes(this.context, ruleId, spaceId); apiKey = decryptedAttributes.apiKey; } catch (err) { throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Decrypt, err); } - const fakeRequest = this.getFakeKibanaRequest(spaceId, apiKey); + const fakeRequest = getFakeKibanaRequest(this.context, spaceId, apiKey); const rulesClient = this.context.getRulesClientWithRequest(fakeRequest); await rulesClient.updateSnoozedUntilTime({ id }); } @@ -613,70 +563,31 @@ export class TaskRunner< const { params: { alertId: ruleId, spaceId }, } = this.taskInstance; - let enabled: boolean; - let apiKey: string | null; - let consumer: string; - try { - const decryptedAttributes = await this.getDecryptedAttributes(ruleId, spaceId); - apiKey = decryptedAttributes.apiKey; - enabled = decryptedAttributes.enabled; - consumer = decryptedAttributes.consumer; - } catch (err) { - throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Decrypt, err); - } - this.ruleConsumer = consumer; - - if (!enabled) { - throw new ErrorWithReason( - RuleExecutionStatusErrorReasons.Disabled, - new Error(`Rule failed to execute because rule ran after it was disabled.`) - ); - } - - const fakeRequest = this.getFakeKibanaRequest(spaceId, apiKey); - - // Get rules client with space level permissions - const rulesClient = this.context.getRulesClientWithRequest(fakeRequest); - - let rule: SanitizedRule; - - // Ensure API key is still valid and user has access - try { - rule = await rulesClient.get({ id: ruleId }); - - if (apm.currentTransaction) { - apm.currentTransaction.name = `Execute Alerting Rule: "${rule.name}"`; - apm.currentTransaction.addLabels({ - alerting_rule_consumer: rule.consumer, - alerting_rule_name: rule.name, - alerting_rule_tags: rule.tags.join(', '), - alerting_rule_type_id: rule.alertTypeId, - alerting_rule_params: JSON.stringify(rule.params), - }); - } - } catch (err) { - throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Read, err); - } - - this.alertingEventLogger.setRuleName(rule.name); + const { rule, fakeRequest, apiKey, rulesClient, validatedParams } = await loadRule({ + paramValidator: this.ruleType.validate?.params, + ruleId, + spaceId, + context: this.context, + ruleTypeRegistry: this.ruleTypeRegistry, + alertingEventLogger: this.alertingEventLogger, + }); - try { - this.ruleTypeRegistry.ensureRuleTypeEnabled(rule.alertTypeId); - } catch (err) { - throw new ErrorWithReason(RuleExecutionStatusErrorReasons.License, err); + if (apm.currentTransaction) { + apm.currentTransaction.name = `Execute Alerting Rule: "${rule.name}"`; + apm.currentTransaction.addLabels({ + alerting_rule_consumer: rule.consumer, + alerting_rule_name: rule.name, + alerting_rule_tags: rule.tags.join(', '), + alerting_rule_type_id: rule.alertTypeId, + alerting_rule_params: JSON.stringify(rule.params), + }); } - if (rule.monitoring) { - if (rule.monitoring.execution.history.length >= MONITORING_HISTORY_LIMIT) { - // Remove the first (oldest) record - rule.monitoring.execution.history.shift(); - } - } return { monitoring: asOk(rule.monitoring), stateWithMetrics: await promiseResult( - this.validateAndExecuteRule(fakeRequest, apiKey, rule) + this.prepareAndExecuteRule(fakeRequest, apiKey, rule, validatedParams) ), schedule: asOk( // fetch the rule again to ensure we return the correct schedule as it may have 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 a477407107e1c5..68c322a8067e5f 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 @@ -114,6 +114,13 @@ export const ALERT_ERROR_DISABLED_REASON = i18n.translate( } ); +export const ALERT_ERROR_VALIDATE_REASON = i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.ruleErrorReasonValidate', + { + defaultMessage: 'An error occurred when validating the rule parameters.', + } +); + export const ALERT_WARNING_MAX_EXECUTABLE_ACTIONS_REASON = i18n.translate( 'xpack.triggersActionsUI.sections.rulesList.ruleWarningReasonMaxExecutableActions', { @@ -136,6 +143,7 @@ export const rulesErrorReasonTranslationsMapping = { license: ALERT_ERROR_LICENSE_REASON, timeout: ALERT_ERROR_TIMEOUT_REASON, disabled: ALERT_ERROR_DISABLED_REASON, + validate: ALERT_ERROR_VALIDATE_REASON, }; export const rulesWarningReasonTranslationsMapping = { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/execution_status.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/execution_status.ts index d5bcd0c7a9ae2b..02edd1d4f37c80 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/execution_status.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/execution_status.ts @@ -183,7 +183,7 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon await ensureAlertUpdatedAtHasNotChanged(alertId, alertUpdatedAt); }); - it('should eventually have error reason "unknown" when appropriate', async () => { + it('should eventually have error reason "validate" when appropriate', async () => { const response = await supertest .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) .set('kbn-xsrf', 'foo') @@ -214,7 +214,7 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon executionStatus = await waitForStatus(alertId, new Set(['error'])); expect(executionStatus.error).to.be.ok(); - expect(executionStatus.error.reason).to.be('unknown'); + expect(executionStatus.error.reason).to.be('validate'); await ensureAlertUpdatedAtHasNotChanged(alertId, alertUpdatedAt); const message = 'params invalid: [param1]: expected value of type [string] but got [number]';