diff --git a/.buildkite/pipelines/on_merge.yml b/.buildkite/pipelines/on_merge.yml index 5c587545897f5..815e4d9adb5e2 100644 --- a/.buildkite/pipelines/on_merge.yml +++ b/.buildkite/pipelines/on_merge.yml @@ -187,6 +187,18 @@ steps: - exit_status: '*' limit: 1 + - command: .buildkite/scripts/steps/functional/osquery_cypress.sh + label: 'Osquery Cypress Tests' + agents: + queue: n2-4-spot + depends_on: build + timeout_in_minutes: 50 + parallelism: 6 + retry: + automatic: + - exit_status: '*' + limit: 1 + - command: '.buildkite/scripts/steps/functional/on_merge_unsupported_ftrs.sh' label: Trigger unsupported ftr tests timeout_in_minutes: 10 diff --git a/.buildkite/pipelines/on_merge_unsupported_ftrs.yml b/.buildkite/pipelines/on_merge_unsupported_ftrs.yml index 904bed2b042ab..6dee27db71659 100644 --- a/.buildkite/pipelines/on_merge_unsupported_ftrs.yml +++ b/.buildkite/pipelines/on_merge_unsupported_ftrs.yml @@ -63,15 +63,3 @@ steps: limit: 3 - exit_status: '*' limit: 1 - - - command: .buildkite/scripts/steps/functional/osquery_cypress.sh - label: 'Osquery Cypress Tests' - agents: - queue: n2-4-spot - depends_on: build - timeout_in_minutes: 50 - parallelism: 6 - retry: - automatic: - - exit_status: '*' - limit: 1 diff --git a/.buildkite/pipelines/pull_request/base.yml b/.buildkite/pipelines/pull_request/base.yml index 5213dfc0e4ab1..c1cd68c6b04ab 100644 --- a/.buildkite/pipelines/pull_request/base.yml +++ b/.buildkite/pipelines/pull_request/base.yml @@ -187,6 +187,18 @@ steps: - exit_status: '*' limit: 1 + - command: .buildkite/scripts/steps/functional/osquery_cypress.sh + label: 'Osquery Cypress Tests' + agents: + queue: n2-4-spot + depends_on: build + timeout_in_minutes: 50 + parallelism: 6 + retry: + automatic: + - exit_status: '*' + limit: 1 + - command: .buildkite/scripts/steps/functional/security_solution_burn.sh label: 'Security Solution Cypress tests, burning changed specs' agents: @@ -198,6 +210,28 @@ steps: automatic: false soft_fail: true + - command: .buildkite/scripts/steps/functional/osquery_cypress_burn.sh + label: 'Osquery Cypress Tests, burning changed specs' + agents: + queue: n2-4-spot + depends_on: build + timeout_in_minutes: 50 + soft_fail: true + retry: + automatic: false + + - command: .buildkite/scripts/steps/functional/security_serverless_osquery.sh + label: 'Serverless Osquery Cypress Tests' + agents: + queue: n2-4-spot + depends_on: build + timeout_in_minutes: 50 + parallelism: 6 + retry: + automatic: + - exit_status: '*' + limit: 1 + # status_exception: Native role management is not enabled in this Elasticsearch instance # - command: .buildkite/scripts/steps/functional/security_serverless_defend_workflows.sh # label: 'Serverless Security Defend Workflows Cypress Tests' diff --git a/.buildkite/pipelines/pull_request/osquery_cypress.yml b/.buildkite/pipelines/pull_request/osquery_cypress.yml deleted file mode 100644 index 49ef00aeb8090..0000000000000 --- a/.buildkite/pipelines/pull_request/osquery_cypress.yml +++ /dev/null @@ -1,34 +0,0 @@ -steps: - - command: .buildkite/scripts/steps/functional/osquery_cypress.sh - label: 'Osquery Cypress Tests' - agents: - queue: n2-4-spot - depends_on: build - timeout_in_minutes: 50 - parallelism: 6 - retry: - automatic: - - exit_status: '*' - limit: 1 - - - command: .buildkite/scripts/steps/functional/security_serverless_osquery.sh - label: 'Serverless Osquery Cypress Tests' - agents: - queue: n2-4-spot - depends_on: build - timeout_in_minutes: 50 - parallelism: 6 - retry: - automatic: - - exit_status: '*' - limit: 1 - - - command: .buildkite/scripts/steps/functional/osquery_cypress_burn.sh - label: 'Osquery Cypress Tests, burning changed specs' - agents: - queue: n2-4-spot - depends_on: build - timeout_in_minutes: 50 - soft_fail: true - retry: - automatic: false diff --git a/.buildkite/scripts/pipelines/pull_request/pipeline.ts b/.buildkite/scripts/pipelines/pull_request/pipeline.ts index 4d6cd774393e0..7a7fa0f59b9c7 100644 --- a/.buildkite/scripts/pipelines/pull_request/pipeline.ts +++ b/.buildkite/scripts/pipelines/pull_request/pipeline.ts @@ -151,14 +151,6 @@ const uploadPipeline = (pipelineContent: string | object) => { pipeline.push(getPipeline('.buildkite/pipelines/pull_request/webpack_bundle_analyzer.yml')); } - if ( - ((await doAnyChangesMatch([/^x-pack\/plugins\/osquery/, /^x-pack\/test\/osquery_cypress/])) || - GITHUB_PR_LABELS.includes('ci:all-cypress-suites')) && - !GITHUB_PR_LABELS.includes('ci:skip-cypress-osquery') - ) { - pipeline.push(getPipeline('.buildkite/pipelines/pull_request/osquery_cypress.yml')); - } - if ( (await doAnyChangesMatch([ /\.docnav\.json$/, diff --git a/package.json b/package.json index 8947a3c3680b0..d2ce2ddb94eab 100644 --- a/package.json +++ b/package.json @@ -1093,7 +1093,7 @@ "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.21.0", "@babel/register": "^7.21.0", - "@babel/traverse": "^7.21.2", + "@babel/traverse": "^7.23.2", "@babel/types": "^7.21.2", "@bazel/ibazel": "^0.16.2", "@bazel/typescript": "4.6.2", diff --git a/packages/cloud/deployment_details/deployment_details_cloudid_input.tsx b/packages/cloud/deployment_details/deployment_details_cloudid_input.tsx index 802e11aa05fcf..aa7b13ef8282a 100644 --- a/packages/cloud/deployment_details/deployment_details_cloudid_input.tsx +++ b/packages/cloud/deployment_details/deployment_details_cloudid_input.tsx @@ -32,7 +32,7 @@ const Label: FC<{ learnMoreUrl: string }> = ({ learnMoreUrl }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); return ( - + {i18n.translate('cloud.deploymentDetails.cloudIDLabel', { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.test.tsx index 33dc820f449fa..e8feefbfd2533 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.test.tsx @@ -54,7 +54,7 @@ describe('API tests', () => { expect(mockHttp.fetch).toHaveBeenCalledWith( '/internal/elastic_assistant/actions/connector/foo/_execute', { - body: '{"params":{"subActionParams":{"model":"gpt-4","messages":[{"role":"user","content":"This is a test"}],"n":1,"stop":null,"temperature":0.2},"subAction":"invokeAI"}}', + body: '{"params":{"subActionParams":{"model":"gpt-4","messages":[{"role":"user","content":"This is a test"}],"n":1,"stop":null,"temperature":0.2},"subAction":"invokeAI"},"assistantLangChain":true}', headers: { 'Content-Type': 'application/json' }, method: 'POST', signal: undefined, @@ -72,12 +72,15 @@ describe('API tests', () => { await fetchConnectorExecuteAction(testProps); - expect(mockHttp.fetch).toHaveBeenCalledWith('/api/actions/connector/foo/_execute', { - body: '{"params":{"subActionParams":{"model":"gpt-4","messages":[{"role":"user","content":"This is a test"}],"n":1,"stop":null,"temperature":0.2},"subAction":"invokeAI"}}', - headers: { 'Content-Type': 'application/json' }, - method: 'POST', - signal: undefined, - }); + expect(mockHttp.fetch).toHaveBeenCalledWith( + '/internal/elastic_assistant/actions/connector/foo/_execute', + { + body: '{"params":{"subActionParams":{"model":"gpt-4","messages":[{"role":"user","content":"This is a test"}],"n":1,"stop":null,"temperature":0.2},"subAction":"invokeAI"},"assistantLangChain":false}', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + signal: undefined, + } + ); }); it('returns API_ERROR when the response status is not ok', async () => { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx index c7c1254656d61..8ccb2e72cfee9 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx @@ -59,19 +59,16 @@ export const fetchConnectorExecuteAction = async ({ subActionParams: body, subAction: 'invokeAI', }, + assistantLangChain, }; try { - const path = assistantLangChain - ? `/internal/elastic_assistant/actions/connector/${apiConfig?.connectorId}/_execute` - : `/api/actions/connector/${apiConfig?.connectorId}/_execute`; - const response = await http.fetch<{ connector_id: string; status: string; data: string; service_message?: string; - }>(path, { + }>(`/internal/elastic_assistant/actions/connector/${apiConfig?.connectorId}/_execute`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/x-pack/plugins/actions/server/lib/axios_utils.test.ts b/x-pack/plugins/actions/server/lib/axios_utils.test.ts index 0cbc3cdde0046..43f16b4863e9a 100644 --- a/x-pack/plugins/actions/server/lib/axios_utils.test.ts +++ b/x-pack/plugins/actions/server/lib/axios_utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import axios from 'axios'; +import axios, { AxiosInstance } from 'axios'; import { Agent as HttpsAgent } from 'https'; import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; @@ -320,6 +320,25 @@ describe('request', () => { expect(axiosMock.mock.calls[0][1].timeout).toBe(360000); expect(axiosMock.mock.calls[1][1].timeout).toBe(360001); }); + + test('throw an error if you use baseUrl in your axios instance', async () => { + await expect(async () => { + await request({ + axios: { + ...axios, + defaults: { + ...axios.defaults, + baseURL: 'https://here-we-go.com', + }, + } as unknown as AxiosInstance, + url: '/test', + logger, + configurationUtilities, + }); + }).rejects.toThrowErrorMatchingInlineSnapshot( + `"Do not use \\"baseURL\\" in the creation of your axios instance because you will mostly break proxy"` + ); + }); }); describe('patch', () => { diff --git a/x-pack/plugins/actions/server/lib/axios_utils.ts b/x-pack/plugins/actions/server/lib/axios_utils.ts index bed2e512761a0..b623f427be681 100644 --- a/x-pack/plugins/actions/server/lib/axios_utils.ts +++ b/x-pack/plugins/actions/server/lib/axios_utils.ts @@ -41,6 +41,11 @@ export const request = async ({ timeout?: number; sslOverrides?: SSLSettings; } & AxiosRequestConfig): Promise => { + if (!isEmpty(axios?.defaults?.baseURL ?? '')) { + throw new Error( + `Do not use "baseURL" in the creation of your axios instance because you will mostly break proxy` + ); + } const { httpAgent, httpsAgent } = getCustomAgents( configurationUtilities, logger, diff --git a/x-pack/plugins/alerting/common/routes/rules_settings/apis/get/index.ts b/x-pack/plugins/alerting/common/routes/rules_settings/apis/get/index.ts new file mode 100644 index 0000000000000..677bf4c52d5ec --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rules_settings/apis/get/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { GetQueryDelaySettingsResponse } from './types/latest'; + +export type { GetQueryDelaySettingsResponse as GetQueryDelaySettingsResponseV1 } from './types/v1'; diff --git a/x-pack/plugins/alerting/common/routes/rules_settings/apis/get/types/latest.ts b/x-pack/plugins/alerting/common/routes/rules_settings/apis/get/types/latest.ts new file mode 100644 index 0000000000000..4cf7e8676c7a7 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rules_settings/apis/get/types/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { GetQueryDelaySettingsResponse } from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/rules_settings/apis/get/types/v1.ts b/x-pack/plugins/alerting/common/routes/rules_settings/apis/get/types/v1.ts new file mode 100644 index 0000000000000..040f3c4813478 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rules_settings/apis/get/types/v1.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TypeOf } from '@kbn/config-schema'; +import { queryDelaySettingsResponseSchemaV1 } from '../../../response'; + +export type GetQueryDelaySettingsResponse = TypeOf; diff --git a/x-pack/plugins/alerting/common/routes/rules_settings/apis/update/index.ts b/x-pack/plugins/alerting/common/routes/rules_settings/apis/update/index.ts new file mode 100644 index 0000000000000..274f279dcd981 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rules_settings/apis/update/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { updateQueryDelaySettingsBodySchema } from './schemas/latest'; +export type { + UpdateQueryDelaySettingsRequestBody, + UpdateQueryDelaySettingsResponse, +} from './types/latest'; + +export { updateQueryDelaySettingsBodySchema as updateQueryDelaySettingsBodySchemaV1 } from './schemas/v1'; +export type { + UpdateQueryDelaySettingsRequestBody as UpdateQueryDelaySettingsRequestBodyV1, + UpdateQueryDelaySettingsResponse as UpdateQueryDelaySettingsResponseV1, +} from './types/v1'; diff --git a/x-pack/plugins/alerting/common/routes/rules_settings/apis/update/schemas/latest.ts b/x-pack/plugins/alerting/common/routes/rules_settings/apis/update/schemas/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rules_settings/apis/update/schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/rules_settings/apis/update/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rules_settings/apis/update/schemas/v1.ts new file mode 100644 index 0000000000000..8e1865b77c273 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rules_settings/apis/update/schemas/v1.ts @@ -0,0 +1,12 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +export const updateQueryDelaySettingsBodySchema = schema.object({ + delay: schema.number(), +}); diff --git a/x-pack/plugins/alerting/common/routes/rules_settings/apis/update/types/latest.ts b/x-pack/plugins/alerting/common/routes/rules_settings/apis/update/types/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rules_settings/apis/update/types/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/rules_settings/apis/update/types/v1.ts b/x-pack/plugins/alerting/common/routes/rules_settings/apis/update/types/v1.ts new file mode 100644 index 0000000000000..0b421e73150f5 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rules_settings/apis/update/types/v1.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TypeOf } from '@kbn/config-schema'; +import { queryDelaySettingsResponseSchemaV1 } from '../../../response'; +import { updateQueryDelaySettingsBodySchemaV1 } from '..'; + +export type UpdateQueryDelaySettingsRequestBody = TypeOf< + typeof updateQueryDelaySettingsBodySchemaV1 +>; + +export type UpdateQueryDelaySettingsResponse = TypeOf; diff --git a/x-pack/plugins/alerting/common/routes/rules_settings/response/index.ts b/x-pack/plugins/alerting/common/routes/rules_settings/response/index.ts new file mode 100644 index 0000000000000..f0a0070f37e74 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rules_settings/response/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { queryDelaySettingsResponseSchema } from './schemas/latest'; +export type { QueryDelaySettingsResponse } from './types/latest'; + +export { queryDelaySettingsResponseSchema as queryDelaySettingsResponseSchemaV1 } from './schemas/v1'; +export type { QueryDelaySettingsResponse as QueryDelaySettingsResponseV1 } from './types/v1'; diff --git a/x-pack/plugins/alerting/common/routes/rules_settings/response/schemas/latest.ts b/x-pack/plugins/alerting/common/routes/rules_settings/response/schemas/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rules_settings/response/schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/rules_settings/response/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rules_settings/response/schemas/v1.ts new file mode 100644 index 0000000000000..59676b865c601 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rules_settings/response/schemas/v1.ts @@ -0,0 +1,20 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +export const queryDelaySettingsResponseBodySchema = schema.object({ + delay: schema.number(), + created_by: schema.nullable(schema.string()), + updated_by: schema.nullable(schema.string()), + created_at: schema.string(), + updated_at: schema.string(), +}); + +export const queryDelaySettingsResponseSchema = schema.object({ + body: queryDelaySettingsResponseBodySchema, +}); diff --git a/x-pack/plugins/alerting/common/routes/rules_settings/response/types/latest.ts b/x-pack/plugins/alerting/common/routes/rules_settings/response/types/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rules_settings/response/types/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/rules_settings/response/types/v1.ts b/x-pack/plugins/alerting/common/routes/rules_settings/response/types/v1.ts new file mode 100644 index 0000000000000..b5671b2d54628 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rules_settings/response/types/v1.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TypeOf } from '@kbn/config-schema'; +import { queryDelaySettingsResponseSchemaV1 } from '..'; + +export type QueryDelaySettingsResponse = TypeOf; diff --git a/x-pack/plugins/alerting/common/rules_settings.ts b/x-pack/plugins/alerting/common/rules_settings.ts index 743d5f4236aaa..953f29144a100 100644 --- a/x-pack/plugins/alerting/common/rules_settings.ts +++ b/x-pack/plugins/alerting/common/rules_settings.ts @@ -20,29 +20,51 @@ export interface RulesSettingsFlappingProperties { export type RulesSettingsFlapping = RulesSettingsFlappingProperties & RulesSettingsModificationMetadata; +export interface RulesSettingsQueryDelayProperties { + delay: number; +} + +export type RulesSettingsQueryDelay = RulesSettingsQueryDelayProperties & + RulesSettingsModificationMetadata; + +export interface RulesSettingsProperties { + flapping?: RulesSettingsFlappingProperties; + queryDelay?: RulesSettingsQueryDelayProperties; +} + export interface RulesSettings { - flapping: RulesSettingsFlapping; + flapping?: RulesSettingsFlapping; + queryDelay?: RulesSettingsQueryDelay; } export const MIN_LOOK_BACK_WINDOW = 2; export const MAX_LOOK_BACK_WINDOW = 20; export const MIN_STATUS_CHANGE_THRESHOLD = 2; export const MAX_STATUS_CHANGE_THRESHOLD = 20; +export const MIN_QUERY_DELAY = 0; +export const MAX_QUERY_DELAY = 60; export const RULES_SETTINGS_FEATURE_ID = 'rulesSettings'; export const ALL_FLAPPING_SETTINGS_SUB_FEATURE_ID = 'allFlappingSettings'; export const READ_FLAPPING_SETTINGS_SUB_FEATURE_ID = 'readFlappingSettings'; +export const ALL_QUERY_DELAY_SETTINGS_SUB_FEATURE_ID = 'allQueryDelaySettings'; +export const READ_QUERY_DELAY_SETTINGS_SUB_FEATURE_ID = 'readQueryDelaySettings'; export const API_PRIVILEGES = { READ_FLAPPING_SETTINGS: 'read-flapping-settings', WRITE_FLAPPING_SETTINGS: 'write-flapping-settings', + READ_QUERY_DELAY_SETTINGS: 'read-query-delay-settings', + WRITE_QUERY_DELAY_SETTINGS: 'write-query-delay-settings', }; export const RULES_SETTINGS_SAVED_OBJECT_TYPE = 'rules-settings'; -export const RULES_SETTINGS_SAVED_OBJECT_ID = 'rules-settings'; +export const RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID = 'rules-settings'; +export const RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID = 'query-delay-settings'; export const DEFAULT_LOOK_BACK_WINDOW = 20; export const DEFAULT_STATUS_CHANGE_THRESHOLD = 4; +export const DEFAULT_QUERY_DELAY = 0; +export const DEFAULT_SERVERLESS_QUERY_DELAY = 15; export const DEFAULT_FLAPPING_SETTINGS: RulesSettingsFlappingProperties = { enabled: true, @@ -54,3 +76,10 @@ export const DISABLE_FLAPPING_SETTINGS: RulesSettingsFlappingProperties = { ...DEFAULT_FLAPPING_SETTINGS, enabled: false, }; + +export const DEFAULT_QUERY_DELAY_SETTINGS: RulesSettingsQueryDelayProperties = { + delay: DEFAULT_QUERY_DELAY, +}; +export const DEFAULT_SERVERLESS_QUERY_DELAY_SETTINGS: RulesSettingsQueryDelayProperties = { + delay: DEFAULT_SERVERLESS_QUERY_DELAY, +}; diff --git a/x-pack/plugins/alerting/server/lib/get_time_range.test.ts b/x-pack/plugins/alerting/server/lib/get_time_range.test.ts new file mode 100644 index 0000000000000..684aea523e3ba --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/get_time_range.test.ts @@ -0,0 +1,61 @@ +/* + * 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 { loggingSystemMock } from '@kbn/core/server/mocks'; +import { getTimeRange } from './get_time_range'; + +describe('getTimeRange', () => { + const logger = loggingSystemMock.create().get(); + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2023-10-04T00:00:00.000Z')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('returns time range with no query delay', () => { + const { dateStart, dateEnd } = getTimeRange(logger, { delay: 0 }, '5m'); + expect(dateStart).toBe('2023-10-03T23:55:00.000Z'); + expect(dateEnd).toBe('2023-10-04T00:00:00.000Z'); + expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 0 seconds'); + }); + + test('returns time range with a query delay', () => { + const { dateStart, dateEnd } = getTimeRange(logger, { delay: 45 }, '5m'); + expect(dateStart).toBe('2023-10-03T23:54:15.000Z'); + expect(dateEnd).toBe('2023-10-03T23:59:15.000Z'); + expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 45 seconds'); + }); + + test('returns time range with no query delay and no time range', () => { + const { dateStart, dateEnd } = getTimeRange(logger, { delay: 0 }); + expect(dateStart).toBe('2023-10-04T00:00:00.000Z'); + expect(dateEnd).toBe('2023-10-04T00:00:00.000Z'); + expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 0 seconds'); + }); + + test('returns time range with a query delay and no time range', () => { + const { dateStart, dateEnd } = getTimeRange(logger, { delay: 45 }); + expect(dateStart).toBe('2023-10-03T23:59:15.000Z'); + expect(dateEnd).toBe('2023-10-03T23:59:15.000Z'); + expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 45 seconds'); + }); + + test('throws an error when the time window is invalid', () => { + expect(() => getTimeRange(logger, { delay: 45 }, '5k')).toThrowErrorMatchingInlineSnapshot( + `"Invalid format for windowSize: \\"5k\\""` + ); + expect(logger.debug).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/get_time_range.ts b/x-pack/plugins/alerting/server/lib/get_time_range.ts new file mode 100644 index 0000000000000..001b5df614ddd --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/get_time_range.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { Logger } from '@kbn/logging'; +import { parseDuration, RulesSettingsQueryDelayProperties } from '../../common'; + +export function getTimeRange( + logger: Logger, + queryDelaySettings: RulesSettingsQueryDelayProperties, + window?: string +) { + let timeWindow: number = 0; + if (window) { + try { + timeWindow = parseDuration(window); + } catch (err) { + throw new Error( + i18n.translate('xpack.alerting.invalidWindowSizeErrorMessage', { + defaultMessage: 'Invalid format for windowSize: "{window}"', + values: { + window, + }, + }) + ); + } + } + logger.debug(`Adjusting rule query time range by ${queryDelaySettings.delay} seconds`); + + const queryDelay = queryDelaySettings.delay * 1000; + const date = Date.now(); + const dateStart = new Date(date - (timeWindow + queryDelay)).toISOString(); + const dateEnd = new Date(date - queryDelay).toISOString(); + + return { dateStart, dateEnd }; +} diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index e9590f883cc53..bb950973afae7 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -190,6 +190,7 @@ export interface AlertingPluginsStart { data: DataPluginStart; dataViews: DataViewsPluginStart; share: SharePluginStart; + serverless?: ServerlessPluginSetup; } export class AlertingPlugin { @@ -503,6 +504,7 @@ export class AlertingPlugin { logger: this.logger, savedObjectsService: core.savedObjects, securityPluginStart: plugins.security, + isServerless: !!plugins.serverless, }); maintenanceWindowClientFactory.initialize({ diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts index a90ad5feba4da..93c66c45ce2af 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -62,6 +62,8 @@ import { registerRulesValueSuggestionsRoute } from './suggestions/values_suggest import { registerFieldsRoute } from './suggestions/fields_rules'; import { bulkGetMaintenanceWindowRoute } from './maintenance_window/apis/bulk_get/bulk_get_maintenance_windows_route'; import { registerAlertsValueSuggestionsRoute } from './suggestions/values_suggestion_alerts'; +import { getQueryDelaySettingsRoute } from './rules_settings/apis/get/get_query_delay_settings'; +import { updateQueryDelaySettingsRoute } from './rules_settings/apis/update/update_query_delay_settings'; export interface RouteOptions { router: IRouter; @@ -133,4 +135,6 @@ export function defineRoutes(opts: RouteOptions) { bulkGetMaintenanceWindowRoute(router, licenseState); getScheduleFrequencyRoute(router, licenseState); bulkUntrackAlertRoute(router, licenseState); + getQueryDelaySettingsRoute(router, licenseState); + updateQueryDelaySettingsRoute(router, licenseState); } diff --git a/x-pack/plugins/alerting/server/routes/rules_settings/apis/get/get_query_delay_settings.test.ts b/x-pack/plugins/alerting/server/routes/rules_settings/apis/get/get_query_delay_settings.test.ts new file mode 100644 index 0000000000000..4102aa80b29af --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rules_settings/apis/get/get_query_delay_settings.test.ts @@ -0,0 +1,72 @@ +/* + * 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 { httpServiceMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../../../../lib/license_state.mock'; +import { mockHandlerArguments } from '../../../_mock_handler_arguments'; +import { + rulesSettingsClientMock, + RulesSettingsClientMock, +} from '../../../../rules_settings_client.mock'; +import { getQueryDelaySettingsRoute } from './get_query_delay_settings'; + +let rulesSettingsClient: RulesSettingsClientMock; + +jest.mock('../../../../lib/license_api_access', () => ({ + verifyApiAccess: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); + rulesSettingsClient = rulesSettingsClientMock.create(); +}); + +describe('getQueryDelaySettingsRoute', () => { + test('gets query delay settings', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getQueryDelaySettingsRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config).toMatchInlineSnapshot(` + Object { + "options": Object { + "tags": Array [ + "access:read-query-delay-settings", + ], + }, + "path": "/internal/alerting/rules/settings/_query_delay", + "validate": Object {}, + } + `); + + (rulesSettingsClient.queryDelay().get as jest.Mock).mockResolvedValue({ + delay: 10, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const [context, req, res] = mockHandlerArguments({ rulesSettingsClient }, {}, ['ok']); + + await handler(context, req, res); + + expect(rulesSettingsClient.queryDelay().get).toHaveBeenCalledTimes(1); + expect(res.ok).toHaveBeenCalledWith({ + body: expect.objectContaining({ + delay: 10, + created_by: 'test name', + updated_by: 'test name', + created_at: expect.any(String), + updated_at: expect.any(String), + }), + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/rules_settings/apis/get/get_query_delay_settings.ts b/x-pack/plugins/alerting/server/routes/rules_settings/apis/get/get_query_delay_settings.ts new file mode 100644 index 0000000000000..ee16be9642977 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rules_settings/apis/get/get_query_delay_settings.ts @@ -0,0 +1,39 @@ +/* + * 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 { IRouter } from '@kbn/core/server'; +import { ILicenseState } from '../../../../lib'; +import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../../../types'; +import { verifyAccessAndContext } from '../../../lib'; +import { API_PRIVILEGES } from '../../../../../common'; +import { transformQueryDelaySettingsToResponseV1 } from '../../transforms'; +import { GetQueryDelaySettingsResponseV1 } from '../../../../../common/routes/rules_settings/apis/get'; + +export const getQueryDelaySettingsRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.get( + { + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_query_delay`, + validate: {}, + options: { + tags: [`access:${API_PRIVILEGES.READ_QUERY_DELAY_SETTINGS}`], + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesSettingsClient = (await context.alerting).getRulesSettingsClient(); + const queryDelaySettings = await rulesSettingsClient.queryDelay().get(); + const response: GetQueryDelaySettingsResponseV1 = + transformQueryDelaySettingsToResponseV1(queryDelaySettings); + + return res.ok(response); + }) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/routes/rules_settings/apis/update/update_query_delay_settings.test.ts b/x-pack/plugins/alerting/server/routes/rules_settings/apis/update/update_query_delay_settings.test.ts new file mode 100644 index 0000000000000..8a506809131ab --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rules_settings/apis/update/update_query_delay_settings.test.ts @@ -0,0 +1,88 @@ +/* + * 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 { httpServiceMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../../../../lib/license_state.mock'; +import { mockHandlerArguments } from '../../../_mock_handler_arguments'; +import { + rulesSettingsClientMock, + RulesSettingsClientMock, +} from '../../../../rules_settings_client.mock'; +import { updateQueryDelaySettingsRoute } from './update_query_delay_settings'; + +let rulesSettingsClient: RulesSettingsClientMock; + +jest.mock('../../../../lib/license_api_access', () => ({ + verifyApiAccess: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); + rulesSettingsClient = rulesSettingsClientMock.create(); +}); + +const mockQueryDelaySettings = { + delay: 10, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +describe('updateQueryDelaySettingsRoute', () => { + test('updates query delay settings', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + updateQueryDelaySettingsRoute(router, licenseState); + + const [config, handler] = router.post.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rules/settings/_query_delay"`); + expect(config.options).toMatchInlineSnapshot(` + Object { + "tags": Array [ + "access:write-query-delay-settings", + ], + } + `); + + (rulesSettingsClient.queryDelay().get as jest.Mock).mockResolvedValue(mockQueryDelaySettings); + (rulesSettingsClient.queryDelay().update as jest.Mock).mockResolvedValue( + mockQueryDelaySettings + ); + + const updateResult = { + delay: 6, + }; + + const [context, req, res] = mockHandlerArguments( + { rulesSettingsClient }, + { + body: updateResult, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(rulesSettingsClient.queryDelay().update).toHaveBeenCalledTimes(1); + expect((rulesSettingsClient.queryDelay().update as jest.Mock).mock.calls[0]) + .toMatchInlineSnapshot(` + Array [ + Object { + "delay": 6, + }, + ] + `); + expect(res.ok).toHaveBeenCalledWith({ + body: expect.objectContaining({ + delay: 10, + }), + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/rules_settings/apis/update/update_query_delay_settings.ts b/x-pack/plugins/alerting/server/routes/rules_settings/apis/update/update_query_delay_settings.ts new file mode 100644 index 0000000000000..050f28942fda7 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rules_settings/apis/update/update_query_delay_settings.ts @@ -0,0 +1,49 @@ +/* + * 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 { IRouter } from '@kbn/core/server'; +import { ILicenseState } from '../../../../lib'; +import { verifyAccessAndContext } from '../../../lib'; +import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../../../types'; +import { API_PRIVILEGES } from '../../../../../common'; +import { + updateQueryDelaySettingsBodySchemaV1, + UpdateQueryDelaySettingsRequestBodyV1, + UpdateQueryDelaySettingsResponseV1, +} from '../../../../../common/routes/rules_settings/apis/update'; +import { transformQueryDelaySettingsToResponseV1 } from '../../transforms'; + +export const updateQueryDelaySettingsRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.post( + { + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_query_delay`, + validate: { + body: updateQueryDelaySettingsBodySchemaV1, + }, + options: { + tags: [`access:${API_PRIVILEGES.WRITE_QUERY_DELAY_SETTINGS}`], + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesSettingsClient = (await context.alerting).getRulesSettingsClient(); + + const body: UpdateQueryDelaySettingsRequestBodyV1 = req.body; + + const updatedQueryDelaySettings = await rulesSettingsClient.queryDelay().update(body); + + const response: UpdateQueryDelaySettingsResponseV1 = + transformQueryDelaySettingsToResponseV1(updatedQueryDelaySettings); + + return res.ok(response); + }) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/routes/rules_settings/transforms/index.ts b/x-pack/plugins/alerting/server/routes/rules_settings/transforms/index.ts new file mode 100644 index 0000000000000..5a7438d7f3ad9 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rules_settings/transforms/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformQueryDelaySettingsToResponse } from './transform_query_delay_settings_to_response/latest'; + +export { transformQueryDelaySettingsToResponse as transformQueryDelaySettingsToResponseV1 } from './transform_query_delay_settings_to_response/v1'; diff --git a/x-pack/plugins/alerting/server/routes/rules_settings/transforms/transform_query_delay_settings_to_response/latest.ts b/x-pack/plugins/alerting/server/routes/rules_settings/transforms/transform_query_delay_settings_to_response/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rules_settings/transforms/transform_query_delay_settings_to_response/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/server/routes/rules_settings/transforms/transform_query_delay_settings_to_response/v1.ts b/x-pack/plugins/alerting/server/routes/rules_settings/transforms/transform_query_delay_settings_to_response/v1.ts new file mode 100644 index 0000000000000..926b702bdbf9c --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rules_settings/transforms/transform_query_delay_settings_to_response/v1.ts @@ -0,0 +1,23 @@ +/* + * 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 { RulesSettingsQueryDelay } from '../../../../../common'; +import { QueryDelaySettingsResponseV1 } from '../../../../../common/routes/rules_settings/response'; + +export const transformQueryDelaySettingsToResponse = ( + settings: RulesSettingsQueryDelay +): QueryDelaySettingsResponseV1 => { + return { + body: { + delay: settings.delay, + created_by: settings.createdBy, + updated_by: settings.updatedBy, + created_at: settings.createdAt, + updated_at: settings.updatedAt, + }, + }; +}; diff --git a/x-pack/plugins/alerting/server/rules_settings_client.mock.ts b/x-pack/plugins/alerting/server/rules_settings_client.mock.ts index 99dcfc388ca23..12703161fdb46 100644 --- a/x-pack/plugins/alerting/server/rules_settings_client.mock.ts +++ b/x-pack/plugins/alerting/server/rules_settings_client.mock.ts @@ -8,11 +8,14 @@ import { RulesSettingsClientApi, RulesSettingsFlappingClientApi, + RulesSettingsQueryDelayClientApi, DEFAULT_FLAPPING_SETTINGS, + DEFAULT_QUERY_DELAY_SETTINGS, } from './types'; export type RulesSettingsClientMock = jest.Mocked; export type RulesSettingsFlappingClientMock = jest.Mocked; +export type RulesSettingsQueryDelayClientMock = jest.Mocked; // Warning: Becareful when resetting all mocks in tests as it would clear // the mock return value on the flapping @@ -20,11 +23,18 @@ const createRulesSettingsClientMock = () => { const flappingMocked: RulesSettingsFlappingClientMock = { get: jest.fn().mockReturnValue(DEFAULT_FLAPPING_SETTINGS), update: jest.fn(), + getSettings: jest.fn(), + createSettings: jest.fn(), + }; + const queryDelayMocked: RulesSettingsQueryDelayClientMock = { + get: jest.fn().mockReturnValue(DEFAULT_QUERY_DELAY_SETTINGS), + update: jest.fn(), + getSettings: jest.fn(), + createSettings: jest.fn(), }; const mocked: RulesSettingsClientMock = { - get: jest.fn(), - create: jest.fn(), flapping: jest.fn().mockReturnValue(flappingMocked), + queryDelay: jest.fn().mockReturnValue(queryDelayMocked), }; return mocked; }; diff --git a/x-pack/plugins/alerting/server/rules_settings_client/flapping/rules_settings_flapping_client.test.ts b/x-pack/plugins/alerting/server/rules_settings_client/flapping/rules_settings_flapping_client.test.ts index 2b02af1327eea..19f978a8985f1 100644 --- a/x-pack/plugins/alerting/server/rules_settings_client/flapping/rules_settings_flapping_client.test.ts +++ b/x-pack/plugins/alerting/server/rules_settings_client/flapping/rules_settings_flapping_client.test.ts @@ -13,10 +13,11 @@ import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mock import { RULES_SETTINGS_FEATURE_ID, RULES_SETTINGS_SAVED_OBJECT_TYPE, - RULES_SETTINGS_SAVED_OBJECT_ID, + RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID, DEFAULT_FLAPPING_SETTINGS, RulesSettings, } from '../../../common'; +import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; const mockDateString = '2019-02-12T21:01:22.479Z'; @@ -39,13 +40,6 @@ const getMockRulesSettings = (): RulesSettings => { const rulesSettingsFlappingClientParams: jest.Mocked = { logger: loggingSystemMock.create().get(), - getOrCreate: jest.fn().mockReturnValue({ - id: RULES_SETTINGS_FEATURE_ID, - type: RULES_SETTINGS_SAVED_OBJECT_TYPE, - attributes: getMockRulesSettings(), - references: [], - version: '123', - }), getModificationMetadata: jest.fn(), savedObjectsClient, }; @@ -58,9 +52,21 @@ const updatedMetadata = { }; describe('RulesSettingsFlappingClient', () => { - beforeEach(() => - rulesSettingsFlappingClientParams.getModificationMetadata.mockResolvedValue(updatedMetadata) - ); + beforeEach(() => { + rulesSettingsFlappingClientParams.getModificationMetadata.mockResolvedValue(updatedMetadata); + savedObjectsClient.get.mockResolvedValue({ + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: getMockRulesSettings(), + references: [], + version: '123', + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + beforeAll(() => { jest.useFakeTimers(); jest.setSystemTime(new Date(mockDateString)); @@ -119,7 +125,7 @@ describe('RulesSettingsFlappingClient', () => { expect(savedObjectsClient.update).toHaveBeenCalledWith( RULES_SETTINGS_SAVED_OBJECT_TYPE, - RULES_SETTINGS_SAVED_OBJECT_ID, + RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID, { flapping: expect.objectContaining({ enabled: false, @@ -192,4 +198,243 @@ describe('RulesSettingsFlappingClient', () => { 'Invalid values,lookBackWindow (10) must be equal to or greater than statusChangeThreshold (20).' ); }); + + test('can create a new flapping settings saved object', async () => { + rulesSettingsFlappingClientParams.getModificationMetadata.mockResolvedValueOnce({ + ...updatedMetadata, + createdBy: 'test name', + updatedBy: 'test name', + }); + const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams); + const mockAttributes = getMockRulesSettings(); + + savedObjectsClient.create.mockResolvedValueOnce({ + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: mockAttributes, + references: [], + }); + + const result = await client.createSettings(); + + expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.create).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + { + flapping: expect.objectContaining({ + enabled: mockAttributes.flapping?.enabled, + lookBackWindow: mockAttributes.flapping?.lookBackWindow, + statusChangeThreshold: mockAttributes.flapping?.statusChangeThreshold, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + }, + { + id: RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID, + overwrite: true, + } + ); + expect(result.attributes).toEqual(mockAttributes); + }); + + test('can get existing flapping settings saved object', async () => { + const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams); + const mockAttributes = getMockRulesSettings(); + + savedObjectsClient.get.mockResolvedValueOnce({ + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: mockAttributes, + references: [], + }); + const result = await client.getSettings(); + expect(result.attributes).toEqual(mockAttributes); + }); + + test('throws if there is no existing saved object to get', async () => { + const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams); + + savedObjectsClient.get.mockRejectedValueOnce( + SavedObjectsErrorHelpers.createGenericNotFoundError( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID + ) + ); + await expect(client.getSettings()).rejects.toThrowError(); + }); + + test('can persist flapping settings when saved object does not exist', async () => { + rulesSettingsFlappingClientParams.getModificationMetadata.mockResolvedValueOnce({ + ...updatedMetadata, + createdBy: 'test name', + updatedBy: 'test name', + }); + const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams); + const mockAttributes = getMockRulesSettings(); + savedObjectsClient.get.mockRejectedValueOnce( + SavedObjectsErrorHelpers.createGenericNotFoundError( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID + ) + ); + + savedObjectsClient.create.mockResolvedValueOnce({ + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: mockAttributes, + references: [], + }); + + const result = await client.get(); + + expect(savedObjectsClient.get).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID + ); + + expect(savedObjectsClient.create).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + { + flapping: expect.objectContaining({ + enabled: mockAttributes.flapping?.enabled, + lookBackWindow: mockAttributes.flapping?.lookBackWindow, + statusChangeThreshold: mockAttributes.flapping?.statusChangeThreshold, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + }, + { + id: RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID, + overwrite: true, + } + ); + expect(result).toEqual(mockAttributes.flapping); + }); + + test('can persist flapping settings when saved object already exists', async () => { + rulesSettingsFlappingClientParams.getModificationMetadata.mockResolvedValueOnce({ + ...updatedMetadata, + createdBy: 'test name', + updatedBy: 'test name', + }); + const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams); + const mockAttributes = getMockRulesSettings(); + + savedObjectsClient.get.mockResolvedValueOnce({ + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: mockAttributes, + references: [], + }); + + const result = await client.get(); + + expect(savedObjectsClient.get).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID + ); + expect(savedObjectsClient.create).not.toHaveBeenCalled(); + expect(result).toEqual(mockAttributes.flapping); + }); + + test('can update flapping settings when saved object does not exist', async () => { + rulesSettingsFlappingClientParams.getModificationMetadata.mockResolvedValueOnce({ + ...updatedMetadata, + createdBy: 'test name', + updatedBy: 'test name', + }); + const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams); + const mockAttributes = getMockRulesSettings(); + + savedObjectsClient.get.mockRejectedValueOnce( + SavedObjectsErrorHelpers.createGenericNotFoundError( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID + ) + ); + + const mockResolve = { + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: mockAttributes, + references: [], + version: '123', + }; + + savedObjectsClient.create.mockResolvedValueOnce(mockResolve); + savedObjectsClient.update.mockResolvedValueOnce({ + ...mockResolve, + attributes: { + flapping: { + ...mockResolve.attributes.flapping, + enabled: false, + lookBackWindow: 5, + statusChangeThreshold: 5, + }, + }, + }); + + // Try to update with new values + const result = await client.update({ + enabled: false, + lookBackWindow: 5, + statusChangeThreshold: 5, + }); + + // Tried to get first, but no results + expect(savedObjectsClient.get).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID + ); + + // So create a new entry + expect(savedObjectsClient.create).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + { + flapping: expect.objectContaining({ + enabled: mockAttributes.flapping?.enabled, + lookBackWindow: mockAttributes.flapping?.lookBackWindow, + statusChangeThreshold: mockAttributes.flapping?.statusChangeThreshold, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + }, + { + id: RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID, + overwrite: true, + } + ); + + // Try to update with version + expect(savedObjectsClient.update).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID, + { + flapping: expect.objectContaining({ + enabled: false, + lookBackWindow: 5, + statusChangeThreshold: 5, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + }, + { version: '123' } + ); + + expect(result).toEqual( + expect.objectContaining({ + enabled: false, + lookBackWindow: 5, + statusChangeThreshold: 5, + }) + ); + }); }); diff --git a/x-pack/plugins/alerting/server/rules_settings_client/flapping/rules_settings_flapping_client.ts b/x-pack/plugins/alerting/server/rules_settings_client/flapping/rules_settings_flapping_client.ts index 88052ea8cfb6e..0bf6f2af025fe 100644 --- a/x-pack/plugins/alerting/server/rules_settings_client/flapping/rules_settings_flapping_client.ts +++ b/x-pack/plugins/alerting/server/rules_settings_client/flapping/rules_settings_flapping_client.ts @@ -6,7 +6,12 @@ */ import Boom from '@hapi/boom'; -import { Logger, SavedObjectsClientContract, SavedObject } from '@kbn/core/server'; +import { + Logger, + SavedObjectsClientContract, + SavedObject, + SavedObjectsErrorHelpers, +} from '@kbn/core/server'; import { RulesSettings, RulesSettingsFlapping, @@ -17,8 +22,11 @@ import { MIN_STATUS_CHANGE_THRESHOLD, MAX_STATUS_CHANGE_THRESHOLD, RULES_SETTINGS_SAVED_OBJECT_TYPE, - RULES_SETTINGS_SAVED_OBJECT_ID, + RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID, + DEFAULT_FLAPPING_SETTINGS, } from '../../../common'; +import { retryIfConflicts } from '../../lib/retry_if_conflicts'; +import { flappingSchema } from '../schemas'; const verifyFlappingSettings = (flappingSettings: RulesSettingsFlappingProperties) => { const { lookBackWindow, statusChangeThreshold } = flappingSettings; @@ -48,30 +56,42 @@ const verifyFlappingSettings = (flappingSettings: RulesSettingsFlappingPropertie export interface RulesSettingsFlappingClientConstructorOptions { readonly logger: Logger; readonly savedObjectsClient: SavedObjectsClientContract; - readonly getOrCreate: () => Promise>; readonly getModificationMetadata: () => Promise; } export class RulesSettingsFlappingClient { private readonly logger: Logger; private readonly savedObjectsClient: SavedObjectsClientContract; - private readonly getOrCreate: () => Promise>; private readonly getModificationMetadata: () => Promise; constructor(options: RulesSettingsFlappingClientConstructorOptions) { this.logger = options.logger; this.savedObjectsClient = options.savedObjectsClient; - this.getOrCreate = options.getOrCreate; this.getModificationMetadata = options.getModificationMetadata; } public async get(): Promise { const rulesSettings = await this.getOrCreate(); + if (!rulesSettings.attributes.flapping) { + this.logger.error('Failed to get flapping rules setting for current space.'); + throw new Error( + 'Failed to get flapping rules setting for current space. Flapping settings are undefined' + ); + } return rulesSettings.attributes.flapping; } public async update(newFlappingProperties: RulesSettingsFlappingProperties) { + return await retryIfConflicts( + this.logger, + 'ruleSettingsClient.flapping.update()', + async () => await this.updateWithOCC(newFlappingProperties) + ); + } + + private async updateWithOCC(newFlappingProperties: RulesSettingsFlappingProperties) { try { + flappingSchema.validate(newFlappingProperties); verifyFlappingSettings(newFlappingProperties); } catch (e) { this.logger.error( @@ -81,14 +101,16 @@ export class RulesSettingsFlappingClient { } const { attributes, version } = await this.getOrCreate(); - const modificationMetadata = await this.getModificationMetadata(); + if (!attributes.flapping) { + throw new Error('Flapping settings are undefined'); + } + const modificationMetadata = await this.getModificationMetadata(); try { const result = await this.savedObjectsClient.update( RULES_SETTINGS_SAVED_OBJECT_TYPE, - RULES_SETTINGS_SAVED_OBJECT_ID, + RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID, { - ...attributes, flapping: { ...attributes.flapping, ...newFlappingProperties, @@ -107,4 +129,55 @@ export class RulesSettingsFlappingClient { throw Boom.boomify(e, { message: errorMessage }); } } + + public async getSettings(): Promise> { + try { + return await this.savedObjectsClient.get( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID + ); + } catch (e) { + this.logger.error(`Failed to get flapping rules setting for current space. Error: ${e}`); + throw e; + } + } + + public async createSettings(): Promise> { + const modificationMetadata = await this.getModificationMetadata(); + try { + return await this.savedObjectsClient.create( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + { + flapping: { + ...DEFAULT_FLAPPING_SETTINGS, + ...modificationMetadata, + }, + }, + { + id: RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID, + overwrite: true, + } + ); + } catch (e) { + this.logger.error(`Failed to create flapping rules setting for current space. Error: ${e}`); + throw e; + } + } + + /** + * Helper function to ensure that a rules-settings saved object always exists. + * Ensures the creation of the saved object is done lazily during retrieval. + */ + private async getOrCreate(): Promise> { + try { + return await this.getSettings(); + } catch (e) { + if (SavedObjectsErrorHelpers.isNotFoundError(e)) { + this.logger.info('Creating new default flapping rules settings for current space.'); + return await this.createSettings(); + } + this.logger.error(`Failed to get flapping rules setting for current space. Error: ${e}`); + throw e; + } + } } diff --git a/x-pack/plugins/alerting/server/rules_settings_client/index.ts b/x-pack/plugins/alerting/server/rules_settings_client/index.ts index efbb3f0b3ccfe..fcbf30b0bcb6c 100644 --- a/x-pack/plugins/alerting/server/rules_settings_client/index.ts +++ b/x-pack/plugins/alerting/server/rules_settings_client/index.ts @@ -7,3 +7,4 @@ export * from './rules_settings_client'; export * from './flapping/rules_settings_flapping_client'; +export * from './query_delay/rules_settings_query_delay_client'; diff --git a/x-pack/plugins/alerting/server/rules_settings_client/query_delay/rules_settings_query_delay_client.test.ts b/x-pack/plugins/alerting/server/rules_settings_client/query_delay/rules_settings_query_delay_client.test.ts new file mode 100644 index 0000000000000..213ece8cd6fe4 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_settings_client/query_delay/rules_settings_query_delay_client.test.ts @@ -0,0 +1,385 @@ +/* + * 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 { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; +import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { + RULES_SETTINGS_FEATURE_ID, + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID, + RulesSettings, + DEFAULT_QUERY_DELAY_SETTINGS, +} from '../../../common'; +import { + RulesSettingsQueryDelayClient, + RulesSettingsQueryDelayClientConstructorOptions, +} from './rules_settings_query_delay_client'; + +const mockDateString = '2019-02-12T21:01:22.479Z'; + +const savedObjectsClient = savedObjectsClientMock.create(); + +const getMockRulesSettings = (): RulesSettings => { + return { + queryDelay: { + delay: DEFAULT_QUERY_DELAY_SETTINGS.delay, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: '2023-03-24T00:00:00.000Z', + updatedAt: '2023-03-24T00:00:00.000Z', + }, + }; +}; + +const rulesSettingsQueryDelayClientParams: jest.Mocked = + { + logger: loggingSystemMock.create().get(), + isServerless: false, + getModificationMetadata: jest.fn(), + savedObjectsClient, + }; + +const updatedMetadata = { + createdAt: '2023-03-26T00:00:00.000Z', + updatedAt: '2023-03-26T00:00:00.000Z', + createdBy: 'updated-user', + updatedBy: 'updated-user', +}; + +describe('RulesSettingsQueryDelayClient', () => { + beforeEach(() => { + rulesSettingsQueryDelayClientParams.getModificationMetadata.mockResolvedValue(updatedMetadata); + savedObjectsClient.get.mockResolvedValue({ + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: getMockRulesSettings(), + references: [], + version: '123', + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(mockDateString)); + }); + + afterAll(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + test('can get query delay settings', async () => { + const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams); + const result = await client.get(); + + expect(result).toEqual( + expect.objectContaining({ + delay: DEFAULT_QUERY_DELAY_SETTINGS.delay, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }) + ); + }); + + test('can update query delay settings', async () => { + const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams); + + const mockResolve = { + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: getMockRulesSettings(), + references: [], + version: '123', + }; + + savedObjectsClient.update.mockResolvedValueOnce({ + ...mockResolve, + attributes: { + queryDelay: { + ...mockResolve.attributes.queryDelay, + delay: 19, + }, + }, + }); + + const result = await client.update({ + delay: 19, + }); + + expect(savedObjectsClient.update).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID, + { + queryDelay: expect.objectContaining({ + delay: 19, + updatedAt: '2023-03-26T00:00:00.000Z', + updatedBy: 'updated-user', + createdBy: 'test name', + createdAt: '2023-03-24T00:00:00.000Z', + }), + }, + { version: '123' } + ); + + expect(result).toEqual( + expect.objectContaining({ + delay: 19, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }) + ); + }); + + test('throws if savedObjectsClient failed to update', async () => { + const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams); + savedObjectsClient.update.mockRejectedValueOnce(new Error('failed!!')); + + await expect( + client.update({ + delay: 19, + }) + ).rejects.toThrowError( + 'savedObjectsClient errored trying to update query delay settings: failed!!' + ); + }); + + test('throws if new query delay setting fails verification', async () => { + const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams); + await expect( + client.update({ + delay: 200, + }) + ).rejects.toThrowError('Invalid query delay value, must be between 0 and 60, but got: 200.'); + }); + + test('can create a new query delay settings saved object', async () => { + rulesSettingsQueryDelayClientParams.getModificationMetadata.mockResolvedValueOnce({ + ...updatedMetadata, + createdBy: 'test name', + updatedBy: 'test name', + }); + const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams); + const mockAttributes = getMockRulesSettings(); + + savedObjectsClient.create.mockResolvedValueOnce({ + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: mockAttributes, + references: [], + }); + + const result = await client.createSettings(); + + expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.create).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + { + queryDelay: expect.objectContaining({ + delay: 0, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + }, + { + id: RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID, + overwrite: true, + } + ); + expect(result.attributes).toEqual(mockAttributes); + }); + + test('can create a new query delay settings saved object with default serverless value', async () => { + rulesSettingsQueryDelayClientParams.getModificationMetadata.mockResolvedValueOnce({ + ...updatedMetadata, + createdBy: 'test name', + updatedBy: 'test name', + }); + const client = new RulesSettingsQueryDelayClient({ + ...rulesSettingsQueryDelayClientParams, + isServerless: true, + }); + + const mockAttributes = getMockRulesSettings(); + + savedObjectsClient.create.mockResolvedValueOnce({ + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: mockAttributes, + references: [], + }); + + const result = await client.createSettings(); + + expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.create).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + { + queryDelay: expect.objectContaining({ + delay: 15, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + }, + { + id: RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID, + overwrite: true, + } + ); + expect(result.attributes).toEqual(mockAttributes); + }); + + test('can get existing query delay settings saved object', async () => { + const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams); + const mockAttributes = getMockRulesSettings(); + + savedObjectsClient.get.mockResolvedValueOnce({ + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: mockAttributes, + references: [], + }); + const result = await client.getSettings(); + expect(result.attributes).toEqual(mockAttributes); + }); + + test('throws if there is no existing saved object to get', async () => { + const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams); + + savedObjectsClient.get.mockRejectedValueOnce( + SavedObjectsErrorHelpers.createGenericNotFoundError( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID + ) + ); + await expect(client.get()).rejects.toThrowError(); + }); + + test('can persist query delay settings when saved object already exists', async () => { + rulesSettingsQueryDelayClientParams.getModificationMetadata.mockResolvedValueOnce({ + ...updatedMetadata, + createdBy: 'test name', + updatedBy: 'test name', + }); + const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams); + const mockAttributes = getMockRulesSettings(); + + savedObjectsClient.get.mockResolvedValueOnce({ + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: mockAttributes, + references: [], + }); + + const result = await client.get(); + + expect(savedObjectsClient.get).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID + ); + expect(savedObjectsClient.create).not.toHaveBeenCalled(); + expect(result).toEqual(mockAttributes.queryDelay); + }); + + test('can update query delay settings when saved object does not exist', async () => { + rulesSettingsQueryDelayClientParams.getModificationMetadata.mockResolvedValueOnce({ + ...updatedMetadata, + createdBy: 'test name', + updatedBy: 'test name', + }); + const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams); + const mockAttributes = getMockRulesSettings(); + + savedObjectsClient.get.mockRejectedValueOnce( + SavedObjectsErrorHelpers.createGenericNotFoundError( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID + ) + ); + + const mockResolve = { + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: mockAttributes, + references: [], + version: '123', + }; + + savedObjectsClient.create.mockResolvedValueOnce(mockResolve); + savedObjectsClient.update.mockResolvedValueOnce({ + ...mockResolve, + attributes: { + queryDelay: { + ...mockResolve.attributes.queryDelay, + delay: 5, + }, + }, + }); + + // Try to update with new values + const result = await client.update({ + delay: 5, + }); + + // Tried to get first, but no results + expect(savedObjectsClient.get).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID + ); + + // So create a new entry + expect(savedObjectsClient.create).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + { + queryDelay: expect.objectContaining({ + delay: mockAttributes.queryDelay?.delay, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + }, + { + id: RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID, + overwrite: true, + } + ); + + // Try to update with version + expect(savedObjectsClient.update).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID, + { + queryDelay: expect.objectContaining({ + delay: 5, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + }, + { version: '123' } + ); + + expect(result).toEqual( + expect.objectContaining({ + delay: 5, + }) + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/rules_settings_client/query_delay/rules_settings_query_delay_client.ts b/x-pack/plugins/alerting/server/rules_settings_client/query_delay/rules_settings_query_delay_client.ts new file mode 100644 index 0000000000000..ac394dca17180 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_settings_client/query_delay/rules_settings_query_delay_client.ts @@ -0,0 +1,179 @@ +/* + * 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 Boom from '@hapi/boom'; +import { + Logger, + SavedObjectsClientContract, + SavedObject, + SavedObjectsErrorHelpers, +} from '@kbn/core/server'; +import { + RulesSettings, + RulesSettingsModificationMetadata, + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID, + RulesSettingsQueryDelayProperties, + MIN_QUERY_DELAY, + MAX_QUERY_DELAY, + RulesSettingsQueryDelay, + DEFAULT_SERVERLESS_QUERY_DELAY_SETTINGS, + DEFAULT_QUERY_DELAY_SETTINGS, +} from '../../../common'; +import { retryIfConflicts } from '../../lib/retry_if_conflicts'; +import { queryDelaySchema } from '../schemas'; + +const verifyQueryDelaySettings = (settings: RulesSettingsQueryDelayProperties) => { + const { delay } = settings; + + if (delay < MIN_QUERY_DELAY || delay > MAX_QUERY_DELAY) { + throw Boom.badRequest( + `Invalid query delay value, must be between ${MIN_QUERY_DELAY} and ${MAX_QUERY_DELAY}, but got: ${delay}.` + ); + } +}; + +export interface RulesSettingsQueryDelayClientConstructorOptions { + readonly logger: Logger; + readonly savedObjectsClient: SavedObjectsClientContract; + readonly isServerless: boolean; + readonly getModificationMetadata: () => Promise; +} + +export class RulesSettingsQueryDelayClient { + private readonly logger: Logger; + private readonly savedObjectsClient: SavedObjectsClientContract; + private readonly isServerless: boolean; + private readonly getModificationMetadata: () => Promise; + + constructor(options: RulesSettingsQueryDelayClientConstructorOptions) { + this.logger = options.logger; + this.savedObjectsClient = options.savedObjectsClient; + this.isServerless = options.isServerless; + this.getModificationMetadata = options.getModificationMetadata; + } + + public async get(): Promise { + const rulesSettings = await this.getOrCreate(); + if (!rulesSettings.attributes.queryDelay) { + this.logger.error('Failed to get query delay rules setting for current space.'); + throw new Error( + 'Failed to get query delay rules setting for current space. Query delay settings are undefined' + ); + } + return rulesSettings.attributes.queryDelay; + } + + public async update(newQueryDelayProperties: RulesSettingsQueryDelayProperties) { + return await retryIfConflicts( + this.logger, + 'ruleSettingsClient.queryDelay.update()', + async () => await this.updateWithOCC(newQueryDelayProperties) + ); + } + + private async updateWithOCC(newQueryDelayProperties: RulesSettingsQueryDelayProperties) { + try { + queryDelaySchema.validate(newQueryDelayProperties); + verifyQueryDelaySettings(newQueryDelayProperties); + } catch (e) { + this.logger.error( + `Failed to verify new query delay settings properties when updating. Error: ${e}` + ); + throw e; + } + + const { attributes, version } = await this.getOrCreate(); + if (!attributes.queryDelay) { + throw new Error('Query delay settings are undefined'); + } + + const modificationMetadata = await this.getModificationMetadata(); + try { + const result = await this.savedObjectsClient.update( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID, + { + queryDelay: { + ...attributes.queryDelay, + ...newQueryDelayProperties, + updatedAt: modificationMetadata.updatedAt, + updatedBy: modificationMetadata.updatedBy, + }, + }, + { + version, + } + ); + + if (!result.attributes.queryDelay) { + throw new Error('Query delay settings are undefined'); + } + return result.attributes.queryDelay; + } catch (e) { + const errorMessage = 'savedObjectsClient errored trying to update query delay settings'; + this.logger.error(`${errorMessage}: ${e}`); + throw Boom.boomify(e, { message: errorMessage }); + } + } + + public async getSettings(): Promise> { + try { + return await this.savedObjectsClient.get( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID + ); + } catch (e) { + this.logger.error(`Failed to get query delay rules setting for current space. Error: ${e}`); + throw e; + } + } + + public async createSettings(): Promise> { + const modificationMetadata = await this.getModificationMetadata(); + const defaultQueryDelaySettings = this.isServerless + ? DEFAULT_SERVERLESS_QUERY_DELAY_SETTINGS + : DEFAULT_QUERY_DELAY_SETTINGS; + try { + return await this.savedObjectsClient.create( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + { + queryDelay: { + ...defaultQueryDelaySettings, + ...modificationMetadata, + }, + }, + { + id: RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID, + overwrite: true, + } + ); + } catch (e) { + this.logger.error( + `Failed to create query delay rules setting for current space. Error: ${e}` + ); + throw e; + } + } + + /** + * Helper function to ensure that a rules-settings saved object always exists. + * Ensures the creation of the saved object is done lazily during retrieval. + */ + private async getOrCreate(): Promise> { + try { + return await this.getSettings(); + } catch (e) { + if (SavedObjectsErrorHelpers.isNotFoundError(e)) { + this.logger.info('Creating new default query delay rules settings for current space.'); + return await this.createSettings(); + } + this.logger.error(`Failed to get query delay rules setting for current space. Error: ${e}`); + throw e; + } + } +} diff --git a/x-pack/plugins/alerting/server/rules_settings_client/rules_settings_client.test.ts b/x-pack/plugins/alerting/server/rules_settings_client/rules_settings_client.test.ts index a40c491b9117e..314e28cd6f245 100644 --- a/x-pack/plugins/alerting/server/rules_settings_client/rules_settings_client.test.ts +++ b/x-pack/plugins/alerting/server/rules_settings_client/rules_settings_client.test.ts @@ -11,16 +11,7 @@ import { } from './rules_settings_client'; import { RulesSettingsFlappingClient } from './flapping/rules_settings_flapping_client'; import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks'; -import { SavedObjectsErrorHelpers } from '@kbn/core/server'; -import { - RULES_SETTINGS_FEATURE_ID, - RULES_SETTINGS_SAVED_OBJECT_TYPE, - RULES_SETTINGS_SAVED_OBJECT_ID, - DEFAULT_FLAPPING_SETTINGS, - RulesSettings, -} from '../../common'; - -const mockDateString = '2019-02-12T21:01:22.479Z'; +import { RulesSettingsQueryDelayClient } from './query_delay/rules_settings_query_delay_client'; const savedObjectsClient = savedObjectsClientMock.create(); @@ -28,258 +19,17 @@ const rulesSettingsClientParams: jest.Mocked { - return { - flapping: { - enabled: DEFAULT_FLAPPING_SETTINGS.enabled, - lookBackWindow: DEFAULT_FLAPPING_SETTINGS.lookBackWindow, - statusChangeThreshold: DEFAULT_FLAPPING_SETTINGS.statusChangeThreshold, - createdBy: 'test name', - updatedBy: 'test name', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - }; + isServerless: false, }; describe('RulesSettingsClient', () => { - beforeAll(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(mockDateString)); - }); - afterAll(() => { - jest.useRealTimers(); - }); - - beforeEach(() => { jest.resetAllMocks(); - rulesSettingsClientParams.getUserName.mockResolvedValue('test name'); }); test('can initialize correctly', async () => { const client = new RulesSettingsClient(rulesSettingsClientParams); expect(client.flapping()).toEqual(expect.any(RulesSettingsFlappingClient)); - }); - - test('can create a new rules settings saved object', async () => { - const client = new RulesSettingsClient(rulesSettingsClientParams); - const mockAttributes = getMockRulesSettings(); - - savedObjectsClient.create.mockResolvedValueOnce({ - id: RULES_SETTINGS_FEATURE_ID, - type: RULES_SETTINGS_SAVED_OBJECT_TYPE, - attributes: mockAttributes, - references: [], - }); - - const result = await client.create(); - - expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.create).toHaveBeenCalledWith( - RULES_SETTINGS_SAVED_OBJECT_TYPE, - { - flapping: expect.objectContaining({ - enabled: mockAttributes.flapping.enabled, - lookBackWindow: mockAttributes.flapping.lookBackWindow, - statusChangeThreshold: mockAttributes.flapping.statusChangeThreshold, - createdBy: 'test name', - updatedBy: 'test name', - createdAt: expect.any(String), - updatedAt: expect.any(String), - }), - }, - { - id: RULES_SETTINGS_SAVED_OBJECT_ID, - overwrite: true, - } - ); - expect(result.attributes).toEqual(mockAttributes); - }); - - test('can get existing rules settings saved object', async () => { - const client = new RulesSettingsClient(rulesSettingsClientParams); - const mockAttributes = getMockRulesSettings(); - - savedObjectsClient.get.mockResolvedValueOnce({ - id: RULES_SETTINGS_FEATURE_ID, - type: RULES_SETTINGS_SAVED_OBJECT_TYPE, - attributes: mockAttributes, - references: [], - }); - const result = await client.get(); - expect(result.attributes).toEqual(mockAttributes); - }); - - test('throws if there is no existing saved object to get', async () => { - const client = new RulesSettingsClient(rulesSettingsClientParams); - - savedObjectsClient.get.mockRejectedValueOnce( - SavedObjectsErrorHelpers.createGenericNotFoundError( - RULES_SETTINGS_SAVED_OBJECT_TYPE, - RULES_SETTINGS_SAVED_OBJECT_ID - ) - ); - await expect(client.get()).rejects.toThrowError(); - }); - - test('can persist flapping settings when saved object does not exist', async () => { - const client = new RulesSettingsClient(rulesSettingsClientParams); - const mockAttributes = getMockRulesSettings(); - savedObjectsClient.get.mockRejectedValueOnce( - SavedObjectsErrorHelpers.createGenericNotFoundError( - RULES_SETTINGS_SAVED_OBJECT_TYPE, - RULES_SETTINGS_SAVED_OBJECT_ID - ) - ); - - savedObjectsClient.create.mockResolvedValueOnce({ - id: RULES_SETTINGS_FEATURE_ID, - type: RULES_SETTINGS_SAVED_OBJECT_TYPE, - attributes: mockAttributes, - references: [], - }); - - const result = await client.flapping().get(); - - expect(savedObjectsClient.get).toHaveBeenCalledWith( - RULES_SETTINGS_SAVED_OBJECT_TYPE, - RULES_SETTINGS_SAVED_OBJECT_ID - ); - - expect(savedObjectsClient.create).toHaveBeenCalledWith( - RULES_SETTINGS_SAVED_OBJECT_TYPE, - { - flapping: expect.objectContaining({ - enabled: mockAttributes.flapping.enabled, - lookBackWindow: mockAttributes.flapping.lookBackWindow, - statusChangeThreshold: mockAttributes.flapping.statusChangeThreshold, - createdBy: 'test name', - updatedBy: 'test name', - createdAt: expect.any(String), - updatedAt: expect.any(String), - }), - }, - { - id: RULES_SETTINGS_SAVED_OBJECT_ID, - overwrite: true, - } - ); - expect(result).toEqual(mockAttributes.flapping); - }); - - test('can persist flapping settings when saved object already exists', async () => { - const client = new RulesSettingsClient(rulesSettingsClientParams); - const mockAttributes = getMockRulesSettings(); - - savedObjectsClient.get.mockResolvedValueOnce({ - id: RULES_SETTINGS_FEATURE_ID, - type: RULES_SETTINGS_SAVED_OBJECT_TYPE, - attributes: mockAttributes, - references: [], - }); - - const result = await client.flapping().get(); - - expect(savedObjectsClient.get).toHaveBeenCalledWith( - RULES_SETTINGS_SAVED_OBJECT_TYPE, - RULES_SETTINGS_SAVED_OBJECT_ID - ); - expect(savedObjectsClient.create).not.toHaveBeenCalled(); - expect(result).toEqual(mockAttributes.flapping); - }); - - test('can update flapping settings when saved object does not exist', async () => { - const client = new RulesSettingsClient(rulesSettingsClientParams); - const mockAttributes = getMockRulesSettings(); - - savedObjectsClient.get.mockRejectedValueOnce( - SavedObjectsErrorHelpers.createGenericNotFoundError( - RULES_SETTINGS_SAVED_OBJECT_TYPE, - RULES_SETTINGS_SAVED_OBJECT_ID - ) - ); - - const mockResolve = { - id: RULES_SETTINGS_FEATURE_ID, - type: RULES_SETTINGS_SAVED_OBJECT_TYPE, - attributes: mockAttributes, - references: [], - version: '123', - }; - - savedObjectsClient.create.mockResolvedValueOnce(mockResolve); - savedObjectsClient.update.mockResolvedValueOnce({ - ...mockResolve, - attributes: { - flapping: { - ...mockResolve.attributes.flapping, - enabled: false, - lookBackWindow: 5, - statusChangeThreshold: 5, - }, - }, - }); - - // Try to update with new values - const result = await client.flapping().update({ - enabled: false, - lookBackWindow: 5, - statusChangeThreshold: 5, - }); - - // Tried to get first, but no results - expect(savedObjectsClient.get).toHaveBeenCalledWith( - RULES_SETTINGS_SAVED_OBJECT_TYPE, - RULES_SETTINGS_SAVED_OBJECT_ID - ); - - // So create a new entry - expect(savedObjectsClient.create).toHaveBeenCalledWith( - RULES_SETTINGS_SAVED_OBJECT_TYPE, - { - flapping: expect.objectContaining({ - enabled: mockAttributes.flapping.enabled, - lookBackWindow: mockAttributes.flapping.lookBackWindow, - statusChangeThreshold: mockAttributes.flapping.statusChangeThreshold, - createdBy: 'test name', - updatedBy: 'test name', - createdAt: expect.any(String), - updatedAt: expect.any(String), - }), - }, - { - id: RULES_SETTINGS_SAVED_OBJECT_ID, - overwrite: true, - } - ); - - // Try to update with version - expect(savedObjectsClient.update).toHaveBeenCalledWith( - RULES_SETTINGS_SAVED_OBJECT_TYPE, - RULES_SETTINGS_SAVED_OBJECT_ID, - { - flapping: expect.objectContaining({ - enabled: false, - lookBackWindow: 5, - statusChangeThreshold: 5, - createdBy: 'test name', - updatedBy: 'test name', - createdAt: expect.any(String), - updatedAt: expect.any(String), - }), - }, - { version: '123' } - ); - - expect(result).toEqual( - expect.objectContaining({ - enabled: false, - lookBackWindow: 5, - statusChangeThreshold: 5, - }) - ); + expect(client.queryDelay()).toEqual(expect.any(RulesSettingsQueryDelayClient)); }); }); diff --git a/x-pack/plugins/alerting/server/rules_settings_client/rules_settings_client.ts b/x-pack/plugins/alerting/server/rules_settings_client/rules_settings_client.ts index f723119d2de80..50e7650f42ff5 100644 --- a/x-pack/plugins/alerting/server/rules_settings_client/rules_settings_client.ts +++ b/x-pack/plugins/alerting/server/rules_settings_client/rules_settings_client.ts @@ -5,24 +5,15 @@ * 2.0. */ -import { - Logger, - SavedObjectsClientContract, - SavedObject, - SavedObjectsErrorHelpers, -} from '@kbn/core/server'; +import { Logger, SavedObjectsClientContract } from '@kbn/core/server'; import { RulesSettingsFlappingClient } from './flapping/rules_settings_flapping_client'; -import { - RulesSettings, - DEFAULT_FLAPPING_SETTINGS, - RULES_SETTINGS_SAVED_OBJECT_TYPE, - RULES_SETTINGS_SAVED_OBJECT_ID, -} from '../../common'; +import { RulesSettingsQueryDelayClient } from './query_delay/rules_settings_query_delay_client'; export interface RulesSettingsClientConstructorOptions { readonly logger: Logger; readonly savedObjectsClient: SavedObjectsClientContract; readonly getUserName: () => Promise; + readonly isServerless: boolean; } export class RulesSettingsClient { @@ -30,16 +21,25 @@ export class RulesSettingsClient { private readonly savedObjectsClient: SavedObjectsClientContract; private readonly getUserName: () => Promise; private readonly _flapping: RulesSettingsFlappingClient; + private readonly _queryDelay: RulesSettingsQueryDelayClient; + private readonly isServerless: boolean; constructor(options: RulesSettingsClientConstructorOptions) { this.logger = options.logger; this.savedObjectsClient = options.savedObjectsClient; this.getUserName = options.getUserName; + this.isServerless = options.isServerless; this._flapping = new RulesSettingsFlappingClient({ logger: this.logger, savedObjectsClient: this.savedObjectsClient, - getOrCreate: this.getOrCreate.bind(this), + getModificationMetadata: this.getModificationMetadata.bind(this), + }); + + this._queryDelay = new RulesSettingsQueryDelayClient({ + logger: this.logger, + savedObjectsClient: this.savedObjectsClient, + isServerless: this.isServerless, getModificationMetadata: this.getModificationMetadata.bind(this), }); } @@ -56,59 +56,11 @@ export class RulesSettingsClient { }; } - public async get(): Promise> { - try { - return await this.savedObjectsClient.get( - RULES_SETTINGS_SAVED_OBJECT_TYPE, - RULES_SETTINGS_SAVED_OBJECT_ID - ); - } catch (e) { - this.logger.error(`Failed to get rules setting for current space. Error: ${e}`); - throw e; - } - } - - public async create(): Promise> { - const modificationMetadata = await this.getModificationMetadata(); - - try { - return await this.savedObjectsClient.create( - RULES_SETTINGS_SAVED_OBJECT_TYPE, - { - flapping: { - ...DEFAULT_FLAPPING_SETTINGS, - ...modificationMetadata, - }, - }, - { - id: RULES_SETTINGS_SAVED_OBJECT_ID, - overwrite: true, - } - ); - } catch (e) { - this.logger.error(`Failed to create rules setting for current space. Error: ${e}`); - throw e; - } - } - - /** - * Helper function to ensure that a rules-settings saved object always exists. - * Ensures the creation of the saved object is done lazily during retrieval. - */ - private async getOrCreate(): Promise> { - try { - return await this.get(); - } catch (e) { - if (SavedObjectsErrorHelpers.isNotFoundError(e)) { - this.logger.info('Creating new default rules settings for current space.'); - return await this.create(); - } - this.logger.error(`Failed to persist rules setting for current space. Error: ${e}`); - throw e; - } - } - public flapping(): RulesSettingsFlappingClient { return this._flapping; } + + public queryDelay(): RulesSettingsQueryDelayClient { + return this._queryDelay; + } } diff --git a/x-pack/plugins/alerting/server/rules_settings_client/schemas/flapping_schema.ts b/x-pack/plugins/alerting/server/rules_settings_client/schemas/flapping_schema.ts new file mode 100644 index 0000000000000..a9765fe826bef --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_settings_client/schemas/flapping_schema.ts @@ -0,0 +1,14 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +export const flappingSchema = schema.object({ + enabled: schema.boolean(), + lookBackWindow: schema.number(), + statusChangeThreshold: schema.number(), +}); diff --git a/x-pack/plugins/alerting/server/rules_settings_client/schemas/index.ts b/x-pack/plugins/alerting/server/rules_settings_client/schemas/index.ts new file mode 100644 index 0000000000000..03ee6f939a233 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_settings_client/schemas/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { flappingSchema } from './flapping_schema'; +export { queryDelaySchema } from './query_delay_schema'; diff --git a/x-pack/plugins/alerting/server/rules_settings_client/schemas/query_delay_schema.ts b/x-pack/plugins/alerting/server/rules_settings_client/schemas/query_delay_schema.ts new file mode 100644 index 0000000000000..613dd9646846f --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_settings_client/schemas/query_delay_schema.ts @@ -0,0 +1,12 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +export const queryDelaySchema = schema.object({ + delay: schema.number(), +}); diff --git a/x-pack/plugins/alerting/server/rules_settings_client_factory.test.ts b/x-pack/plugins/alerting/server/rules_settings_client_factory.test.ts index a91e6697a4d8c..bb278dbf50cdd 100644 --- a/x-pack/plugins/alerting/server/rules_settings_client_factory.test.ts +++ b/x-pack/plugins/alerting/server/rules_settings_client_factory.test.ts @@ -30,6 +30,7 @@ const securityPluginStart = securityMock.createStart(); const rulesSettingsClientFactoryParams: jest.Mocked = { logger: loggingSystemMock.create().get(), savedObjectsService, + isServerless: false, }; beforeEach(() => { @@ -58,6 +59,7 @@ test('creates a rules settings client with proper constructor arguments when sec logger: rulesSettingsClientFactoryParams.logger, savedObjectsClient, getUserName: expect.any(Function), + isServerless: false, }); }); @@ -80,6 +82,7 @@ test('creates a rules settings client with proper constructor arguments', async logger: rulesSettingsClientFactoryParams.logger, savedObjectsClient, getUserName: expect.any(Function), + isServerless: false, }); }); @@ -106,6 +109,7 @@ test('creates an unauthorized rules settings client', async () => { logger: rulesSettingsClientFactoryParams.logger, savedObjectsClient, getUserName: expect.any(Function), + isServerless: false, }); }); diff --git a/x-pack/plugins/alerting/server/rules_settings_client_factory.ts b/x-pack/plugins/alerting/server/rules_settings_client_factory.ts index 619e498c6b988..f69068ee3cb65 100644 --- a/x-pack/plugins/alerting/server/rules_settings_client_factory.ts +++ b/x-pack/plugins/alerting/server/rules_settings_client_factory.ts @@ -18,6 +18,7 @@ import { RULES_SETTINGS_SAVED_OBJECT_TYPE } from '../common'; export interface RulesSettingsClientFactoryOpts { logger: Logger; savedObjectsService: SavedObjectsServiceStart; + isServerless: boolean; securityPluginStart?: SecurityPluginStart; } @@ -26,6 +27,7 @@ export class RulesSettingsClientFactory { private logger!: Logger; private savedObjectsService!: SavedObjectsServiceStart; private securityPluginStart?: SecurityPluginStart; + private isServerless = false; public initialize(options: RulesSettingsClientFactoryOpts) { if (this.isInitialized) { @@ -35,6 +37,7 @@ export class RulesSettingsClientFactory { this.logger = options.logger; this.savedObjectsService = options.savedObjectsService; this.securityPluginStart = options.securityPluginStart; + this.isServerless = options.isServerless; } private createRulesSettingsClient(request: KibanaRequest, withAuth: boolean) { @@ -54,6 +57,7 @@ export class RulesSettingsClientFactory { const user = securityPluginStart.authc.getCurrentUser(request); return user ? user.username : null; }, + isServerless: this.isServerless, }); } diff --git a/x-pack/plugins/alerting/server/rules_settings_feature.ts b/x-pack/plugins/alerting/server/rules_settings_feature.ts index 5c420fd32bd3c..2d39b9290a0cd 100644 --- a/x-pack/plugins/alerting/server/rules_settings_feature.ts +++ b/x-pack/plugins/alerting/server/rules_settings_feature.ts @@ -14,6 +14,8 @@ import { ALL_FLAPPING_SETTINGS_SUB_FEATURE_ID, API_PRIVILEGES, RULES_SETTINGS_SAVED_OBJECT_TYPE, + ALL_QUERY_DELAY_SETTINGS_SUB_FEATURE_ID, + READ_QUERY_DELAY_SETTINGS_SUB_FEATURE_ID, } from '../common'; export const rulesSettingsFeature: KibanaFeatureConfig = { @@ -87,5 +89,42 @@ export const rulesSettingsFeature: KibanaFeatureConfig = { }, ], }, + { + name: i18n.translate('xpack.alerting.feature.queryDelaySettingsSubFeatureName', { + defaultMessage: 'Query delay', + }), + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + api: [ + API_PRIVILEGES.READ_QUERY_DELAY_SETTINGS, + API_PRIVILEGES.WRITE_QUERY_DELAY_SETTINGS, + ], + name: 'All', + id: ALL_QUERY_DELAY_SETTINGS_SUB_FEATURE_ID, + includeIn: 'all', + savedObject: { + all: [RULES_SETTINGS_SAVED_OBJECT_TYPE], + read: [], + }, + ui: ['writeQueryDelaySettingsUI', 'readQueryDelaySettingsUI'], + }, + { + api: [API_PRIVILEGES.READ_QUERY_DELAY_SETTINGS], + name: 'Read', + id: READ_QUERY_DELAY_SETTINGS_SUB_FEATURE_ID, + includeIn: 'read', + savedObject: { + all: [], + read: [RULES_SETTINGS_SAVED_OBJECT_TYPE], + }, + ui: ['readQueryDelaySettingsUI'], + }, + ], + }, + ], + }, ], }; 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 4dd391cc4f801..3cc9c2359c272 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -80,6 +80,7 @@ import { RuleResultService } from '../monitoring/rule_result_service'; import { LegacyAlertsClient } from '../alerts_client'; import { IAlertsClient } from '../alerts_client/types'; import { MaintenanceWindow } from '../application/maintenance_window/types'; +import { getTimeRange } from '../lib/get_time_range'; const FALLBACK_RETRY_INTERVAL = '5m'; const CONNECTIVITY_RETRY_INTERVAL = '5m'; @@ -324,6 +325,7 @@ export class TaskRunner< const rulesSettingsClient = this.context.getRulesSettingsClientWithRequest(fakeRequest); const flappingSettings = await rulesSettingsClient.flapping().get(); + const queryDelaySettings = await rulesSettingsClient.queryDelay().get(); const alertsClientParams = { logger: this.logger, @@ -514,6 +516,8 @@ export class TaskRunner< logger: this.logger, flappingSettings, ...(maintenanceWindowIds.length ? { maintenanceWindowIds } : {}), + getTimeRange: (timeWindow) => + getTimeRange(this.logger, queryDelaySettings, timeWindow), }) ); diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 66e2c3bfa6069..26eed5f254bc9 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -28,7 +28,11 @@ import { Filter } from '@kbn/es-query'; import { RuleTypeRegistry as OrigruleTypeRegistry } from './rule_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { RulesClient } from './rules_client'; -import { RulesSettingsClient, RulesSettingsFlappingClient } from './rules_settings_client'; +import { + RulesSettingsClient, + RulesSettingsFlappingClient, + RulesSettingsQueryDelayClient, +} from './rules_settings_client'; import { MaintenanceWindowClient } from './maintenance_window_client'; export * from '../common'; import { @@ -135,6 +139,7 @@ export interface RuleExecutorOptions< namespace?: string; flappingSettings: RulesSettingsFlappingProperties; maintenanceWindowIds?: string[]; + getTimeRange: (timeWindow?: string) => { dateStart: string; dateEnd: string }; } export interface RuleParamsAndRefs { @@ -372,6 +377,7 @@ export type RulesClientApi = PublicMethodsOf; export type RulesSettingsClientApi = PublicMethodsOf; export type RulesSettingsFlappingClientApi = PublicMethodsOf; +export type RulesSettingsQueryDelayClientApi = PublicMethodsOf; export type MaintenanceWindowClientApi = PublicMethodsOf; diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts index 611ca43499c6a..28f08cdc72811 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts @@ -103,7 +103,11 @@ describe('Transaction duration anomaly alert', () => { ml, }); - const params = { anomalySeverityType: ML_ANOMALY_SEVERITY.MINOR }; + const params = { + anomalySeverityType: ML_ANOMALY_SEVERITY.MINOR, + windowSize: 5, + windowUnit: 'm', + }; await executor({ params }); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts index d318f6ec4a44d..b74db63061306 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts @@ -97,7 +97,13 @@ export function registerAnomalyRuleType({ producer: 'apm', minimumLicenseRequired: 'basic', isExportable: true, - executor: async ({ params, services, spaceId, startedAt }) => { + executor: async ({ + params, + services, + spaceId, + startedAt, + getTimeRange, + }) => { if (!ml) { return { state: {} }; } @@ -144,12 +150,14 @@ export function registerAnomalyRuleType({ } // start time must be at least 30, does like this to support rules created before this change where default was 15 - const startTime = Math.min( - datemath.parse('now-30m')!.valueOf(), + const window = + datemath.parse('now-30m')!.valueOf() > datemath - .parse(`now-${ruleParams.windowSize}${ruleParams.windowUnit}`) - ?.valueOf() || 0 - ); + .parse(`now-${ruleParams.windowSize}${ruleParams.windowUnit}`)! + .valueOf() + ? '30m' + : `${ruleParams.windowSize}${ruleParams.windowUnit}`; + const { dateStart } = getTimeRange(window); const jobIds = mlJobs.map((job) => job.jobId); const anomalySearchParams = { @@ -165,7 +173,7 @@ export function registerAnomalyRuleType({ { range: { timestamp: { - gte: startTime, + gte: dateStart, format: 'epoch_millis', }, }, diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts index 4ecade37780d9..6a0a5b8fdbbe6 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts @@ -104,6 +104,7 @@ export function registerErrorCountRuleType({ services, spaceId, startedAt, + getTimeRange, }) => { const allGroupByFields = getAllGroupByFields( ApmRuleType.ErrorCount, @@ -131,6 +132,10 @@ export function registerErrorCountRuleType({ ] : []; + const { dateStart } = getTimeRange( + `${ruleParams.windowSize}${ruleParams.windowUnit}` + ); + const searchParams = { index: indices.error, body: { @@ -142,7 +147,7 @@ export function registerErrorCountRuleType({ { range: { '@timestamp': { - gte: `now-${ruleParams.windowSize}${ruleParams.windowUnit}`, + gte: dateStart, }, }, }, diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts index cbfb5db627100..3789a55e6e4e0 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts @@ -111,7 +111,12 @@ export function registerTransactionDurationRuleType({ producer: APM_SERVER_FEATURE_ID, minimumLicenseRequired: 'basic', isExportable: true, - executor: async ({ params: ruleParams, services, spaceId }) => { + executor: async ({ + params: ruleParams, + services, + spaceId, + getTimeRange, + }) => { const allGroupByFields = getAllGroupByFields( ApmRuleType.TransactionDuration, ruleParams.groupBy @@ -152,6 +157,10 @@ export function registerTransactionDurationRuleType({ ] : []; + const { dateStart } = getTimeRange( + `${ruleParams.windowSize}${ruleParams.windowUnit}` + ); + const searchParams = { index, body: { @@ -163,7 +172,7 @@ export function registerTransactionDurationRuleType({ { range: { '@timestamp': { - gte: `now-${ruleParams.windowSize}${ruleParams.windowUnit}`, + gte: dateStart, }, }, }, diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts index d7c700c42071c..7e3c7bce8baf3 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts @@ -113,6 +113,7 @@ export function registerTransactionErrorRateRuleType({ spaceId, params: ruleParams, startedAt, + getTimeRange, }) => { const allGroupByFields = getAllGroupByFields( ApmRuleType.TransactionErrorRate, @@ -154,6 +155,10 @@ export function registerTransactionErrorRateRuleType({ ] : []; + const { dateStart } = getTimeRange( + `${ruleParams.windowSize}${ruleParams.windowUnit}` + ); + const searchParams = { index, body: { @@ -165,7 +170,7 @@ export function registerTransactionErrorRateRuleType({ { range: { '@timestamp': { - gte: `now-${ruleParams.windowSize}${ruleParams.windowUnit}`, + gte: dateStart, }, }, }, diff --git a/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts b/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts index 185af5a5496e7..b4b5692708456 100644 --- a/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts +++ b/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts @@ -95,6 +95,10 @@ export const createRuleTypeMocks = () => { }, startedAt: new Date(), flappingSettings: DEFAULT_FLAPPING_SETTINGS, + getTimeRange: () => { + const date = new Date(Date.now()).toISOString(); + return { dateStart: date, dateEnd: date }; + }, }); }, }; diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/action_result_data.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/action_result_data.ts index 17aa4b83ca67b..dbc095a334cea 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/action_result_data.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/action_result_data.ts @@ -5,4 +5,7 @@ * 2.0. */ -export const mockActionResponse = 'Yes, your name is Andrew. How can I assist you further, Andrew?'; +export const mockActionResponse = { + message: 'Yes, your name is Andrew. How can I assist you further, Andrew?', + usage: { prompt_tokens: 4, completion_tokens: 10, total_tokens: 14 }, +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/executor.ts b/x-pack/plugins/elastic_assistant/server/lib/executor.ts new file mode 100644 index 0000000000000..936e3781731d8 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/executor.ts @@ -0,0 +1,43 @@ +/* + * 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 { get } from 'lodash/fp'; +import { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; +import { KibanaRequest } from '@kbn/core-http-server'; +import { RequestBody } from './langchain/types'; + +interface Props { + actions: ActionsPluginStart; + connectorId: string; + request: KibanaRequest; +} +interface StaticResponse { + connector_id: string; + data: string; + status: string; +} + +export const executeAction = async ({ + actions, + request, + connectorId, +}: Props): Promise => { + const actionsClient = await actions.getActionsClientWithRequest(request); + const actionResult = await actionsClient.execute({ + actionId: connectorId, + params: request.body.params, + }); + const content = get('data.message', actionResult); + if (typeof content === 'string') { + return { + connector_id: connectorId, + data: content, // the response from the actions framework + status: 'ok', + }; + } + throw new Error('Unexpected action result'); +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.test.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.test.ts index b5f8fa7e88c74..5c27cdef4d3e1 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.test.ts @@ -51,6 +51,7 @@ const mockRequest: KibanaRequest = { }, subAction: 'invokeAI', }, + assistantLangChain: true, }, } as KibanaRequest; @@ -72,7 +73,7 @@ describe('ActionsClientLlm', () => { await actionsClientLlm._call(prompt); // ignore the result - expect(actionsClientLlm.getActionResultData()).toEqual(mockActionResponse); + expect(actionsClientLlm.getActionResultData()).toEqual(mockActionResponse.message); }); }); @@ -141,7 +142,7 @@ describe('ActionsClientLlm', () => { }); it('rejects with the expected error the message has invalid content', async () => { - const invalidContent = 1234; + const invalidContent = { message: 1234 }; mockExecute.mockImplementation(() => ({ data: invalidContent, diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.ts index e4403b64d6e0d..f499452e1d764 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/llm/actions_client_llm.ts @@ -92,9 +92,8 @@ export class ActionsClientLlm extends LLM { `${LLM_TYPE}: action result status is error: ${actionResult?.message} - ${actionResult?.serviceMessage}` ); } - // TODO: handle errors from the connector - const content = get('data', actionResult); + const content = get('data.message', actionResult); if (typeof content !== 'string') { throw new Error( diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts index b65822524f1cd..1b533e49c4cfe 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts @@ -105,6 +105,7 @@ export const postEvaluateRoute = ( messages: [], }, }, + assistantLangChain: true, }, }; diff --git a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts index fa0afb540dc30..507246670833c 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts @@ -19,6 +19,13 @@ import { coreMock } from '@kbn/core/server/mocks'; jest.mock('../lib/build_response', () => ({ buildResponse: jest.fn().mockImplementation((x) => x), })); +jest.mock('../lib/executor', () => ({ + executeAction: jest.fn().mockImplementation((x) => ({ + connector_id: 'mock-connector-id', + data: mockActionResponse, + status: 'ok', + })), +})); jest.mock('../lib/langchain/execute_custom_llm_chain', () => ({ callAgentExecutor: jest.fn().mockImplementation( @@ -82,6 +89,7 @@ const mockRequest = { }, subAction: 'invokeAI', }, + assistantLangChain: true, }, }; @@ -97,7 +105,38 @@ describe('postActionsConnectorExecuteRoute', () => { jest.clearAllMocks(); }); - it('returns the expected response', async () => { + it('returns the expected response when assistantLangChain=false', async () => { + const mockRouter = { + post: jest.fn().mockImplementation(async (_, handler) => { + const result = await handler( + mockContext, + { + ...mockRequest, + body: { + ...mockRequest.body, + assistantLangChain: false, + }, + }, + mockResponse + ); + + expect(result).toEqual({ + body: { + connector_id: 'mock-connector-id', + data: mockActionResponse, + status: 'ok', + }, + }); + }), + }; + + await postActionsConnectorExecuteRoute( + mockRouter as unknown as IRouter, + mockGetElser + ); + }); + + it('returns the expected response when assistantLangChain=true', async () => { const mockRouter = { post: jest.fn().mockImplementation(async (_, handler) => { const result = await handler(mockContext, mockRequest, mockResponse); diff --git a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts index 5303796d1c983..8da820288ae1b 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts @@ -7,6 +7,7 @@ import { IRouter, Logger } from '@kbn/core/server'; import { transformError } from '@kbn/securitysolution-es-utils'; +import { executeAction } from '../lib/executor'; import { POST_ACTIONS_CONNECTOR_EXECUTE } from '../../common/constants'; import { getLangChainMessages } from '../lib/langchain/helpers'; import { buildResponse } from '../lib/build_response'; @@ -41,6 +42,14 @@ export const postActionsConnectorExecuteRoute = ( // get the actions plugin start contract from the request context: const actions = (await context.elasticAssistant).actions; + // if not langchain, call execute action directly and return the response: + if (!request.body.assistantLangChain) { + const result = await executeAction({ actions, request, connectorId }); + return response.ok({ + body: result, + }); + } + // get a scoped esClient for assistant memory const esClient = (await context.core).elasticsearch.client.asCurrentUser; diff --git a/x-pack/plugins/elastic_assistant/server/schemas/post_actions_connector_execute.ts b/x-pack/plugins/elastic_assistant/server/schemas/post_actions_connector_execute.ts index b30ccd94e105b..7a8d52e725722 100644 --- a/x-pack/plugins/elastic_assistant/server/schemas/post_actions_connector_execute.ts +++ b/x-pack/plugins/elastic_assistant/server/schemas/post_actions_connector_execute.ts @@ -34,6 +34,7 @@ export const PostActionsConnectorExecuteBody = t.type({ ]), subAction: t.string, }), + assistantLangChain: t.boolean, }); export type PostActionsConnectorExecuteBodyInputs = t.TypeOf< diff --git a/x-pack/plugins/fleet/common/services/index.ts b/x-pack/plugins/fleet/common/services/index.ts index 04f74404ba382..663cd27deab73 100644 --- a/x-pack/plugins/fleet/common/services/index.ts +++ b/x-pack/plugins/fleet/common/services/index.ts @@ -18,7 +18,7 @@ export { isPackageLimited, doesAgentPolicyAlreadyIncludePackage } from './limite export { isValidNamespace, INVALID_NAMESPACE_CHARACTERS } from './is_valid_namespace'; export { isDiffPathProtocol } from './is_diff_path_protocol'; export { LicenseService } from './license'; -export { isAgentUpgradeable } from './is_agent_upgradeable'; +export * from './is_agent_upgradeable'; export { isAgentRequestDiagnosticsSupported, MINIMUM_DIAGNOSTICS_AGENT_VERSION, diff --git a/x-pack/plugins/fleet/common/services/is_agent_upgradeable.test.ts b/x-pack/plugins/fleet/common/services/is_agent_upgradeable.test.ts index 8a3f3ce8d59ac..ad8138abbce7f 100644 --- a/x-pack/plugins/fleet/common/services/is_agent_upgradeable.test.ts +++ b/x-pack/plugins/fleet/common/services/is_agent_upgradeable.test.ts @@ -7,7 +7,7 @@ import type { Agent } from '../types/models/agent'; -import { isAgentUpgradeable } from './is_agent_upgradeable'; +import { getRecentUpgradeInfoForAgent, isAgentUpgradeable } from './is_agent_upgradeable'; const getAgent = ({ version, @@ -15,14 +15,14 @@ const getAgent = ({ unenrolling = false, unenrolled = false, updating = false, - upgraded = false, + minutesSinceUpgrade, }: { version: string; upgradeable?: boolean; unenrolling?: boolean; unenrolled?: boolean; updating?: boolean; - upgraded?: boolean; + minutesSinceUpgrade?: number; }): Agent => { const agent: Agent = { id: 'de9006e1-54a7-4320-b24e-927e6fe518a8', @@ -101,8 +101,8 @@ const getAgent = ({ if (updating) { agent.upgrade_started_at = new Date(Date.now()).toISOString(); } - if (upgraded) { - agent.upgraded_at = new Date(Date.now()).toISOString(); + if (minutesSinceUpgrade) { + agent.upgraded_at = new Date(Date.now() - minutesSinceUpgrade * 6e4).toISOString(); } return agent; }; @@ -176,9 +176,42 @@ describe('Fleet - isAgentUpgradeable', () => { isAgentUpgradeable(getAgent({ version: '7.9.0', upgradeable: true, updating: true }), '8.0.0') ).toBe(false); }); - it('returns true if agent was recently upgraded', () => { + it('returns false if the agent reports upgradeable but was upgraded less than 10 minutes ago', () => { expect( - isAgentUpgradeable(getAgent({ version: '7.9.0', upgradeable: true, upgraded: true }), '8.0.0') + isAgentUpgradeable( + getAgent({ version: '7.9.0', upgradeable: true, minutesSinceUpgrade: 9 }), + '8.0.0' + ) + ).toBe(false); + }); + it('returns true if agent reports upgradeable and was upgraded more than 10 minutes ago', () => { + expect( + isAgentUpgradeable( + getAgent({ version: '7.9.0', upgradeable: true, minutesSinceUpgrade: 11 }), + '8.0.0' + ) + ).toBe(true); + }); +}); + +describe('hasAgentBeenUpgradedRecently', () => { + it('returns true if the agent was upgraded less than 10 minutes ago', () => { + expect( + getRecentUpgradeInfoForAgent(getAgent({ version: '7.9.0', minutesSinceUpgrade: 9 })) + .hasBeenUpgradedRecently ).toBe(true); }); + + it('returns false if the agent was upgraded more than 10 minutes ago', () => { + expect( + getRecentUpgradeInfoForAgent(getAgent({ version: '7.9.0', minutesSinceUpgrade: 11 })) + .hasBeenUpgradedRecently + ).toBe(false); + }); + + it('returns false if the agent does not have an upgrade_at field', () => { + expect( + getRecentUpgradeInfoForAgent(getAgent({ version: '7.9.0' })).hasBeenUpgradedRecently + ).toBe(false); + }); }); diff --git a/x-pack/plugins/fleet/common/services/is_agent_upgradeable.ts b/x-pack/plugins/fleet/common/services/is_agent_upgradeable.ts index f896d6cf97bd4..c7bd21c45af4a 100644 --- a/x-pack/plugins/fleet/common/services/is_agent_upgradeable.ts +++ b/x-pack/plugins/fleet/common/services/is_agent_upgradeable.ts @@ -11,6 +11,8 @@ import semverGt from 'semver/functions/gt'; import type { Agent } from '../types'; +export const AGENT_UPGRADE_COOLDOWN_IN_MIN = 10; + export function isAgentUpgradeable( agent: Agent, latestAgentVersion: string, @@ -32,6 +34,10 @@ export function isAgentUpgradeable( if (agent.upgrade_started_at && !agent.upgraded_at) { return false; } + // check that the agent has not been upgraded more recently than the monitoring period + if (getRecentUpgradeInfoForAgent(agent).hasBeenUpgradedRecently) { + return false; + } if (versionToUpgrade !== undefined) { return isNotDowngrade(agentVersion, versionToUpgrade); } @@ -56,3 +62,21 @@ const isNotDowngrade = (agentVersion: string, versionToUpgrade: string) => { return semverGt(versionToUpgradeNumber, agentVersionNumber); }; + +export function getRecentUpgradeInfoForAgent(agent: Agent): { + hasBeenUpgradedRecently: boolean; + timeToWaitMs: number; +} { + if (!agent.upgraded_at) { + return { + hasBeenUpgradedRecently: false, + timeToWaitMs: 0, + }; + } + + const elaspedSinceUpgradeInMillis = Date.now() - Date.parse(agent.upgraded_at); + const timeToWaitMs = AGENT_UPGRADE_COOLDOWN_IN_MIN * 6e4 - elaspedSinceUpgradeInMillis; + const hasBeenUpgradedRecently = elaspedSinceUpgradeInMillis / 6e4 < AGENT_UPGRADE_COOLDOWN_IN_MIN; + + return { hasBeenUpgradedRecently, timeToWaitMs }; +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx index 7b35927657959..d361349b3f327 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx @@ -27,6 +27,8 @@ import type { EuiComboBoxOptionOption } from '@elastic/eui'; import semverGt from 'semver/functions/gt'; import semverLt from 'semver/functions/lt'; +import { AGENT_UPGRADE_COOLDOWN_IN_MIN } from '../../../../../../../common/services'; + import { getMinVersion } from '../../../../../../../common/services/get_min_max_version'; import { AGENT_UPDATING_TIMEOUT_HOURS, @@ -361,14 +363,32 @@ export const AgentUpgradeAgentModal: React.FunctionComponent ) : isSingleAgent ? ( - + <> +

+ +

+ {isUpdating && ( +

+ + + +

+ )} + ) : ( { const docs = (calledWith as estypes.BulkRequest)?.body ?.filter((i: any) => i.doc) .map((i: any) => i.doc); + expect(ids).toEqual(idsToAction); for (const doc of docs!) { expect(doc).toHaveProperty('upgrade_started_at'); diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade_action_runner.ts b/x-pack/plugins/fleet/server/services/agents/upgrade_action_runner.ts index 014a9bec89739..b6ab67e5fb5e3 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade_action_runner.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade_action_runner.ts @@ -10,7 +10,7 @@ import type { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/ import { v4 as uuidv4 } from 'uuid'; import moment from 'moment'; -import { isAgentUpgradeable } from '../../../common/services'; +import { getRecentUpgradeInfoForAgent, isAgentUpgradeable } from '../../../common/services'; import type { Agent } from '../../types'; @@ -76,9 +76,10 @@ export async function upgradeBatch( const latestAgentVersion = await getLatestAvailableVersion(); const upgradeableResults = await Promise.allSettled( agentsToCheckUpgradeable.map(async (agent) => { - // Filter out agents currently unenrolling, unenrolled, or not upgradeable b/c of version check + // Filter out agents currently unenrolling, unenrolled, recently upgraded or not upgradeable b/c of version check const isNotAllowed = - !options.force && !isAgentUpgradeable(agent, latestAgentVersion, options.version); + getRecentUpgradeInfoForAgent(agent).hasBeenUpgradedRecently || + (!options.force && !isAgentUpgradeable(agent, latestAgentVersion, options.version)); if (isNotAllowed) { throw new FleetError(`Agent ${agent.id} is not upgradeable`); } diff --git a/x-pack/plugins/fleet/server/services/files/mocks.ts b/x-pack/plugins/fleet/server/services/files/mocks.ts index 23c0482b7e111..2000f8eefc02b 100644 --- a/x-pack/plugins/fleet/server/services/files/mocks.ts +++ b/x-pack/plugins/fleet/server/services/files/mocks.ts @@ -10,12 +10,12 @@ import { Readable } from 'stream'; import type { estypes } from '@elastic/elasticsearch'; import type { + FleetFile, FleetFromHostFileClientInterface, FleetToHostFileClientInterface, HapiReadableStream, + HostUploadedFileMetadata, } from './types'; -import type { FleetFile } from './types'; -import type { HostUploadedFileMetadata } from './types'; export const createFleetFromHostFilesClientMock = (): jest.Mocked => { diff --git a/x-pack/plugins/grokdebugger/public/components/grok_debugger/grok_debugger.js b/x-pack/plugins/grokdebugger/public/components/grok_debugger/grok_debugger.js index 7eb4e9412edaa..cfae6749879f3 100644 --- a/x-pack/plugins/grokdebugger/public/components/grok_debugger/grok_debugger.js +++ b/x-pack/plugins/grokdebugger/public/components/grok_debugger/grok_debugger.js @@ -11,15 +11,7 @@ import { i18n } from '@kbn/i18n'; // eslint-disable-next-line no-restricted-imports import isEmpty from 'lodash/isEmpty'; -import { - EuiForm, - EuiButton, - EuiPage, - EuiPageBody, - EuiPageContent_Deprecated as EuiPageContent, - EuiPageContentBody_Deprecated as EuiPageContentBody, - EuiSpacer, -} from '@elastic/eui'; +import { EuiForm, EuiButton, EuiPage, EuiPageBody, EuiPageSection, EuiSpacer } from '@elastic/eui'; import { EventInput } from '../event_input'; import { PatternInput } from '../pattern_input'; import { CustomPatternsInput } from '../custom_patterns_input'; @@ -126,33 +118,31 @@ export class GrokDebuggerComponent extends React.Component { return ( - - - - - - - + + + + + + + + - - - - - - - - - + + + + + ); diff --git a/x-pack/plugins/grokdebugger/public/components/inactive_license.js b/x-pack/plugins/grokdebugger/public/components/inactive_license.js index f373c65bf9bb1..b9abdd6a4706f 100644 --- a/x-pack/plugins/grokdebugger/public/components/inactive_license.js +++ b/x-pack/plugins/grokdebugger/public/components/inactive_license.js @@ -13,8 +13,7 @@ import { EuiCode, EuiPage, EuiPageBody, - EuiPageContent_Deprecated as EuiPageContent, - EuiPageContentBody_Deprecated as EuiPageContentBody, + EuiPageSection, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -43,49 +42,47 @@ export const InactiveLicenseSlate = () => { return ( - - - - -

- - {trialLicense}, {basicLicense},{' '} - {goldLicense} - - ), - platinumLicenseType: {platinumLicense}, - }} - /> -

-

- - {registerLicenseLinkLabel} - - ), - }} - /> -

-
-
-
-
+ + + +

+ + {trialLicense}, {basicLicense},{' '} + {goldLicense} + + ), + platinumLicenseType: {platinumLicense}, + }} + /> +

+

+ + {registerLicenseLinkLabel} + + ), + }} + /> +

+
+
+
); diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.test.ts index a47008a0fbaf1..29c4bfe0a159a 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.test.ts @@ -83,6 +83,10 @@ const mockOptions = { }, logger, flappingSettings: DEFAULT_FLAPPING_SETTINGS, + getTimeRange: () => { + const date = new Date().toISOString(); + return { dateStart: date, dateEnd: date }; + }, }; const setEvaluationResults = (response: Record) => { diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index b5b0c1973307b..65fa6e5a070ce 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -42,6 +42,8 @@ const logger = { const mockNow = new Date('2023-09-20T15:11:04.105Z'); +const STARTED_AT_MOCK_DATE = new Date(); + const mockOptions = { executionId: '', startedAt: mockNow, @@ -73,6 +75,10 @@ const mockOptions = { }, logger, flappingSettings: DEFAULT_FLAPPING_SETTINGS, + getTimeRange: () => { + const date = STARTED_AT_MOCK_DATE.toISOString(); + return { dateStart: date, dateEnd: date }; + }, }; const setEvaluationResults = (response: Array>) => { diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap index 1ce5f5a38b043..8a845149b95e8 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -1,675 +1,690 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`UploadLicense should display a modal when license requires acknowledgement 1`] = ` -
-

- Upload your license -

-
-
-

- Your license key is a JSON file with a signature attached. -

-

- Uploading a license will replace your current - - license. -

-
-
+

+ Upload your license +

+
+
+

+ Your license key is a JSON file with a signature attached. +

+

+ Uploading a license will replace your current + + license. +

+
+
-
-
-
-
-
-
+
+ -
-
+
- - Upload - - + + Upload + + +
-
+ `; exports[`UploadLicense should display an error when ES says license is expired 1`] = ` -
-

- Upload your license -

-
-
-

- Your license key is a JSON file with a signature attached. -

-

- Uploading a license will replace your current - - license. -

-
-
+

+ Upload your license +

+