diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index f95237a76a8611..a6fc2e0d0ba928 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -45,6 +45,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/plugin_api_integration/config.ts'), require.resolve('../test/rule_registry/security_and_spaces/config_basic.ts'), require.resolve('../test/rule_registry/security_and_spaces/config_trial.ts'), + require.resolve('../test/rule_registry/spaces_only/config_basic.ts'), require.resolve('../test/rule_registry/spaces_only/config_trial.ts'), require.resolve('../test/security_api_integration/saml.config.ts'), require.resolve('../test/security_api_integration/session_idle.config.ts'), diff --git a/x-pack/test/api_integration/apis/observability/index.ts b/x-pack/test/api_integration/apis/observability/index.ts deleted file mode 100644 index 5136ff9b3d468b..00000000000000 --- a/x-pack/test/api_integration/apis/observability/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * 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 { FtrProviderContext } from '../../ftr_provider_context'; - -export default function observabilityApiIntegrationTests({ loadTestFile }: FtrProviderContext) { - describe('Observability specs', () => { - loadTestFile(require.resolve('./annotations')); - }); -} diff --git a/x-pack/test/apm_api_integration/tests/alerts/rule_registry.spec.ts b/x-pack/test/apm_api_integration/tests/alerts/rule_registry.spec.ts deleted file mode 100644 index efa8aa3ace9dc7..00000000000000 --- a/x-pack/test/apm_api_integration/tests/alerts/rule_registry.spec.ts +++ /dev/null @@ -1,576 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from '@kbn/expect'; -import { - ALERT_DURATION, - ALERT_END, - ALERT_RULE_UUID, - ALERT_START, - ALERT_STATUS, - ALERT_UUID, - EVENT_KIND, - VERSION, -} from '@kbn/rule-data-utils'; -import { merge, omit } from 'lodash'; -import { FtrProviderContext } from '../../common/ftr_provider_context'; - -interface Alert { - schedule: { - interval: string; - }; - updatedAt: string; - executionStatus: { - lastExecutionDate: string; - status: string; - }; - updatedBy: string; - id: string; - params: Record; - scheduledTaskId: string; -} - -export default function ApiTest({ getService }: FtrProviderContext) { - const registry = getService('registry'); - const supertest = getService('legacySupertestAsApmWriteUser'); - const es = getService('es'); - - const MAX_POLLS = 10; - const BULK_INDEX_DELAY = 1000; - const INDEXING_DELAY = 5000; - - const getAlertsTargetIndicesUrl = - '/api/observability/rules/alerts/dynamic_index_pattern?namespace=default®istrationContexts=observability.apm®istrationContexts='; - - const getAlertsTargetIndices = async () => - supertest.get(getAlertsTargetIndicesUrl).send().set('kbn-xsrf', 'foo'); - const APM_METRIC_INDEX_NAME = 'apm-8.0.0-transaction'; - - const createTransactionMetric = (override: Record) => { - const now = Date.now(); - - const time = now - INDEXING_DELAY; - - return merge( - { - '@timestamp': new Date(time).toISOString(), - service: { - name: 'opbeans-go', - }, - event: { - outcome: 'success', - }, - transaction: { - duration: { - histogram: { - values: [1000000], - counts: [1], - }, - }, - type: 'request', - }, - processor: { - event: 'metric', - }, - observer: { - version_major: 7, - }, - }, - override - ); - }; - - async function waitUntilNextExecution( - alert: Alert, - intervalInSeconds: number = 1, - count: number = 0 - ): Promise { - await new Promise((resolve) => { - setTimeout(resolve, intervalInSeconds * 1000); - }); - - const { body, status } = await supertest - .get(`/api/alerts/alert/${alert.id}`) - .set('kbn-xsrf', 'foo'); - - const { body: targetIndices, status: targetIndicesStatus } = await getAlertsTargetIndices(); - if (targetIndices.length === 0) { - const error = new Error('Error getting alert'); - Object.assign(error, { response: { body: targetIndices, status: targetIndicesStatus } }); - throw error; - } - - if (status >= 300) { - const error = new Error('Error getting alert'); - Object.assign(error, { response: { body, status } }); - throw error; - } - - const nextAlert = body as Alert; - - if (nextAlert.executionStatus.lastExecutionDate !== alert.executionStatus.lastExecutionDate) { - await new Promise((resolve) => { - setTimeout(resolve, BULK_INDEX_DELAY); - }); - - /** - * When calling refresh on an index pattern .alerts-observability.apm.alerts* (as was originally the hard-coded string in this test) - * The response from Elasticsearch is a 200, even if no indices which match that index pattern have been created. - * When calling refresh on a concrete index alias .alerts-observability.apm.alerts-default for instance, - * we receive a 404 error index_not_found_exception when no indices have been created which match that alias (obviously). - * Since we are receiving a concrete index alias from the observability api instead of a kibana index pattern - * and we understand / expect that this index does not exist at certain points of the test, we can try-catch at certain points without caring if the call fails. - * There are points in the code where we do want to ensure we get the appropriate error message back - */ - try { - await es.indices.refresh({ - index: targetIndices[0], - }); - // eslint-disable-next-line no-empty - } catch (exc) {} - return nextAlert; - } - - if (count >= MAX_POLLS) { - throw new Error('Maximum number of polls exceeded'); - } - - return waitUntilNextExecution(alert, intervalInSeconds, count + 1); - } - - registry.when('Rule registry with write enabled', { config: 'rules', archives: [] }, () => { - it('does not bootstrap indices on plugin startup', async () => { - const { body: targetIndices } = await getAlertsTargetIndices(); - try { - const res = await es.indices.get({ - index: targetIndices[0], - expand_wildcards: 'open', - allow_no_indices: true, - }); - expect(res).to.be.empty(); - } catch (exc) { - expect(exc.statusCode).to.eql(404); - } - }); - - describe('when creating a rule', () => { - let createResponse: { - alert: Alert; - status: number; - }; - - before(async () => { - await es.indices.create({ - index: APM_METRIC_INDEX_NAME, - body: { - mappings: { - dynamic: 'strict', - properties: { - event: { - properties: { - outcome: { - type: 'keyword', - }, - }, - }, - processor: { - properties: { - event: { - type: 'keyword', - }, - }, - }, - observer: { - properties: { - version_major: { - type: 'byte', - }, - }, - }, - service: { - properties: { - name: { - type: 'keyword', - }, - environment: { - type: 'keyword', - }, - }, - }, - transaction: { - properties: { - type: { - type: 'keyword', - }, - duration: { - properties: { - histogram: { - type: 'histogram', - }, - }, - }, - }, - }, - '@timestamp': { - type: 'date', - }, - }, - }, - }, - }); - - const body = { - params: { - threshold: 30, - windowSize: 5, - windowUnit: 'm', - transactionType: 'request', - environment: 'ENVIRONMENT_ALL', - serviceName: 'opbeans-go', - }, - consumer: 'apm', - alertTypeId: 'apm.transaction_error_rate', - schedule: { interval: '5s' }, - actions: [], - tags: ['apm', 'service.name:opbeans-go'], - notifyWhen: 'onActionGroupChange', - name: 'Failed transaction rate threshold | opbeans-go', - }; - - const { body: response, status } = await supertest - .post('/api/alerts/alert') - .send(body) - .set('kbn-xsrf', 'foo'); - - createResponse = { - alert: response, - status, - }; - }); - - after(async () => { - const { body: targetIndices } = await getAlertsTargetIndices(); - if (createResponse.alert) { - const { body, status } = await supertest - .delete(`/api/alerts/alert/${createResponse.alert.id}`) - .set('kbn-xsrf', 'foo'); - - if (status >= 300) { - const error = new Error('Error deleting alert'); - Object.assign(error, { response: { body, status } }); - throw error; - } - } - - await es.deleteByQuery({ - index: targetIndices[0], - body: { - query: { - match_all: {}, - }, - }, - refresh: true, - }); - - await es.indices.delete({ - index: APM_METRIC_INDEX_NAME, - }); - }); - - it('writes alerts data to the alert indices', async () => { - expect(createResponse.status).to.be.below(299); - - expect(createResponse.alert).not.to.be(undefined); - let alert = await waitUntilNextExecution(createResponse.alert); - - const { body: targetIndices } = await getAlertsTargetIndices(); - - try { - const res = await es.search({ - index: targetIndices[0], - body: { - query: { - term: { - [EVENT_KIND]: 'signal', - }, - }, - size: 1, - sort: { - '@timestamp': 'desc', - }, - }, - }); - expect(res).to.be.empty(); - } catch (exc) { - expect(exc.message).contain('index_not_found_exception'); - } - - await es.index({ - index: APM_METRIC_INDEX_NAME, - body: createTransactionMetric({ - event: { - outcome: 'success', - }, - }), - refresh: true, - }); - - alert = await waitUntilNextExecution(alert); - - try { - const res = await es.search({ - index: targetIndices[0], - body: { - query: { - term: { - [EVENT_KIND]: 'signal', - }, - }, - size: 1, - sort: { - '@timestamp': 'desc', - }, - }, - }); - expect(res).to.be.empty(); - } catch (exc) { - expect(exc.message).contain('index_not_found_exception'); - } - - await es.index({ - index: APM_METRIC_INDEX_NAME, - body: createTransactionMetric({ - event: { - outcome: 'failure', - }, - }), - refresh: true, - }); - - alert = await waitUntilNextExecution(alert); - - const afterViolatingDataResponse = await es.search({ - index: targetIndices[0], - body: { - query: { - term: { - [EVENT_KIND]: 'signal', - }, - }, - size: 1, - sort: { - '@timestamp': 'desc', - }, - _source: false, - fields: [{ field: '*', include_unmapped: true }], - }, - }); - - expect(afterViolatingDataResponse.hits.hits.length).to.be(1); - - const alertEvent = afterViolatingDataResponse.hits.hits[0].fields as Record; - - const exclude = ['@timestamp', ALERT_START, ALERT_UUID, ALERT_RULE_UUID, VERSION]; - - const toCompare = omit(alertEvent, exclude); - - expectSnapshot(toCompare).toMatchInline(` - Object { - "event.action": Array [ - "open", - ], - "event.kind": Array [ - "signal", - ], - "kibana.alert.duration.us": Array [ - 0, - ], - "kibana.alert.evaluation.threshold": Array [ - 30, - ], - "kibana.alert.evaluation.value": Array [ - 50, - ], - "kibana.alert.instance.id": Array [ - "apm.transaction_error_rate_opbeans-go_request_ENVIRONMENT_NOT_DEFINED", - ], - "kibana.alert.reason": Array [ - "Failed transactions rate is greater than 30% (current value is 50%) for opbeans-go", - ], - "kibana.alert.rule.category": Array [ - "Failed transaction rate threshold", - ], - "kibana.alert.rule.consumer": Array [ - "apm", - ], - "kibana.alert.rule.name": Array [ - "Failed transaction rate threshold | opbeans-go", - ], - "kibana.alert.rule.producer": Array [ - "apm", - ], - "kibana.alert.rule.rule_type_id": Array [ - "apm.transaction_error_rate", - ], - "kibana.alert.status": Array [ - "active", - ], - "kibana.alert.workflow_status": Array [ - "open", - ], - "kibana.space_ids": Array [ - "default", - ], - "processor.event": Array [ - "transaction", - ], - "service.name": Array [ - "opbeans-go", - ], - "tags": Array [ - "apm", - "service.name:opbeans-go", - ], - "transaction.type": Array [ - "request", - ], - } - `); - - await es.bulk({ - index: APM_METRIC_INDEX_NAME, - body: [ - { index: {} }, - createTransactionMetric({ - event: { - outcome: 'success', - }, - }), - { index: {} }, - createTransactionMetric({ - event: { - outcome: 'success', - }, - }), - ], - refresh: true, - }); - - alert = await waitUntilNextExecution(alert); - - const afterRecoveryResponse = await es.search({ - index: targetIndices[0], - body: { - query: { - term: { - [EVENT_KIND]: 'signal', - }, - }, - size: 1, - sort: { - '@timestamp': 'desc', - }, - _source: false, - fields: [{ field: '*', include_unmapped: true }], - }, - }); - - expect(afterRecoveryResponse.hits.hits.length).to.be(1); - - const recoveredAlertEvent = afterRecoveryResponse.hits.hits[0].fields as Record< - string, - any - >; - - expect(recoveredAlertEvent[ALERT_STATUS]?.[0]).to.eql('recovered'); - expect(recoveredAlertEvent[ALERT_DURATION]?.[0]).to.be.greaterThan(0); - expect(new Date(recoveredAlertEvent[ALERT_END]?.[0]).getTime()).to.be.greaterThan(0); - - expectSnapshot(omit(recoveredAlertEvent, exclude.concat([ALERT_DURATION, ALERT_END]))) - .toMatchInline(` - Object { - "event.action": Array [ - "close", - ], - "event.kind": Array [ - "signal", - ], - "kibana.alert.evaluation.threshold": Array [ - 30, - ], - "kibana.alert.evaluation.value": Array [ - 50, - ], - "kibana.alert.instance.id": Array [ - "apm.transaction_error_rate_opbeans-go_request_ENVIRONMENT_NOT_DEFINED", - ], - "kibana.alert.reason": Array [ - "Failed transactions rate is greater than 30% (current value is 50%) for opbeans-go", - ], - "kibana.alert.rule.category": Array [ - "Failed transaction rate threshold", - ], - "kibana.alert.rule.consumer": Array [ - "apm", - ], - "kibana.alert.rule.name": Array [ - "Failed transaction rate threshold | opbeans-go", - ], - "kibana.alert.rule.producer": Array [ - "apm", - ], - "kibana.alert.rule.rule_type_id": Array [ - "apm.transaction_error_rate", - ], - "kibana.alert.status": Array [ - "recovered", - ], - "kibana.alert.workflow_status": Array [ - "open", - ], - "kibana.space_ids": Array [ - "default", - ], - "processor.event": Array [ - "transaction", - ], - "service.name": Array [ - "opbeans-go", - ], - "tags": Array [ - "apm", - "service.name:opbeans-go", - ], - "transaction.type": Array [ - "request", - ], - } - `); - }); - }); - }); - - registry.when('Rule registry with write not enabled', { config: 'basic', archives: [] }, () => { - it('does not bootstrap the apm rule indices', async () => { - const { body: targetIndices } = await getAlertsTargetIndices(); - const errorOrUndefined = await es.indices - .get({ - index: targetIndices[0], - expand_wildcards: 'open', - allow_no_indices: false, - }) - .then(() => {}) - .catch((error) => { - return error.toString(); - }); - - expect(errorOrUndefined).not.to.be(undefined); - - expect(errorOrUndefined).to.contain('index_not_found_exception'); - }); - }); -} diff --git a/x-pack/test/rule_registry/common/constants.ts b/x-pack/test/rule_registry/common/constants.ts new file mode 100644 index 00000000000000..55b632352ffc47 --- /dev/null +++ b/x-pack/test/rule_registry/common/constants.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const APM_METRIC_INDEX_NAME = 'apm-8.0.0-transaction'; +export const MAX_POLLS = 10; +export const BULK_INDEX_DELAY = 1000; +export const INDEXING_DELAY = 5000; +export const ALERTS_TARGET_INDICES_URL = + '/api/observability/rules/alerts/dynamic_index_pattern?namespace=default®istrationContexts=observability.apm®istrationContexts='; diff --git a/x-pack/test/rule_registry/common/lib/helpers/cleanup_target_indices.ts b/x-pack/test/rule_registry/common/lib/helpers/cleanup_target_indices.ts new file mode 100644 index 00000000000000..a249c57d8c3dcc --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/helpers/cleanup_target_indices.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { GetService } from '../../types'; +import { User } from '../authentication/types'; +import { getAlertsTargetIndices } from './get_alerts_target_indices'; + +export const cleanupTargetIndices = async (getService: GetService, user: User, spaceId: string) => { + const es = getService('es'); + try { + const { body: targetIndices } = await getAlertsTargetIndices(getService, user, spaceId); + const aliasMap = await es.indices.getAlias({ + name: targetIndices, + allow_no_indices: true, + expand_wildcards: 'open', + }); + const indices = Object.keys(aliasMap); + expect(indices.length > 0).to.be(true); + return es.indices.delete({ index: indices }, { ignore: [404] }); + } catch (error) { + if (error.meta.statusCode !== 404) { + throw error; + } + } +}; diff --git a/x-pack/test/rule_registry/common/lib/helpers/create_alert.ts b/x-pack/test/rule_registry/common/lib/helpers/create_alert.ts new file mode 100644 index 00000000000000..40d43ac80d210a --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/helpers/create_alert.ts @@ -0,0 +1,25 @@ +/* + * 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 { User } from '../authentication/types'; +import { GetService, AlertDef } from '../../types'; +import { getSpaceUrlPrefix } from '../authentication/spaces'; + +export const createAlert = async ( + getService: GetService, + user: User, + spaceId: string, + alertDef: AlertDef +) => { + const supertest = getService('supertestWithoutAuth'); + const { body: response, status } = await supertest + .post(`${getSpaceUrlPrefix(spaceId)}/api/alerts/alert`) + .auth(user.username, user.password) + .send(alertDef) + .set('kbn-xsrf', 'foo'); + return { alert: response, status }; +}; diff --git a/x-pack/test/rule_registry/common/lib/helpers/create_apm_metric_index.ts b/x-pack/test/rule_registry/common/lib/helpers/create_apm_metric_index.ts new file mode 100644 index 00000000000000..fab0f79d5a15f9 --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/helpers/create_apm_metric_index.ts @@ -0,0 +1,70 @@ +/* + * 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 { APM_METRIC_INDEX_NAME } from '../../constants'; +import { GetService } from '../../types'; + +export const createApmMetricIndex = async (getService: GetService) => { + const es = getService('es'); + await es.indices.create({ + index: APM_METRIC_INDEX_NAME, + body: { + mappings: { + dynamic: 'strict', + properties: { + event: { + properties: { + outcome: { + type: 'keyword', + }, + }, + }, + processor: { + properties: { + event: { + type: 'keyword', + }, + }, + }, + observer: { + properties: { + version_major: { + type: 'byte', + }, + }, + }, + service: { + properties: { + name: { + type: 'keyword', + }, + environment: { + type: 'keyword', + }, + }, + }, + transaction: { + properties: { + type: { + type: 'keyword', + }, + duration: { + properties: { + histogram: { + type: 'histogram', + }, + }, + }, + }, + }, + '@timestamp': { + type: 'date', + }, + }, + }, + }, + }); +}; diff --git a/x-pack/test/rule_registry/common/lib/helpers/create_transaction_metric.ts b/x-pack/test/rule_registry/common/lib/helpers/create_transaction_metric.ts new file mode 100644 index 00000000000000..0675f919fbc4e2 --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/helpers/create_transaction_metric.ts @@ -0,0 +1,42 @@ +/* + * 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 { merge } from 'lodash'; +import { INDEXING_DELAY } from '../../constants'; + +export const createTransactionMetric = (override: Record) => { + const now = Date.now(); + const time = now - INDEXING_DELAY; + + return merge( + { + '@timestamp': new Date(time).toISOString(), + service: { + name: 'opbeans-go', + }, + event: { + outcome: 'success', + }, + transaction: { + duration: { + histogram: { + values: [1000000], + counts: [1], + }, + }, + type: 'request', + }, + processor: { + event: 'metric', + }, + observer: { + version_major: 7, + }, + }, + override + ); +}; diff --git a/x-pack/test/rule_registry/common/lib/helpers/delete_alert.ts b/x-pack/test/rule_registry/common/lib/helpers/delete_alert.ts new file mode 100644 index 00000000000000..209b182a958c5d --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/helpers/delete_alert.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 { APM_METRIC_INDEX_NAME } from '../../constants'; +import { GetService } from '../../types'; +import { getSpaceUrlPrefix } from '../authentication/spaces'; +import { User } from '../authentication/types'; +import { getAlertsTargetIndices } from './get_alerts_target_indices'; + +export const deleteAlert = async ( + getService: GetService, + user: User, + spaceId: string, + id: string | undefined +) => { + const es = getService('es'); + const supertest = getService('supertestWithoutAuth'); + const { body: targetIndices } = await getAlertsTargetIndices(getService, user, spaceId); + if (id) { + const { body, status } = await supertest + .delete(`${getSpaceUrlPrefix(spaceId)}/api/alerts/alert/${id}`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo'); + + if (status >= 300) { + const error = new Error('Error deleting alert'); + Object.assign(error, { response: { body, status } }); + throw error; + } + } + + await es.deleteByQuery({ + index: targetIndices[0], + body: { + query: { + match_all: {}, + }, + }, + refresh: true, + }); + + await es.indices.delete({ + index: APM_METRIC_INDEX_NAME, + }); +}; diff --git a/x-pack/test/rule_registry/common/lib/helpers/get_alerts_target_indices.ts b/x-pack/test/rule_registry/common/lib/helpers/get_alerts_target_indices.ts new file mode 100644 index 00000000000000..c78f97f30de746 --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/helpers/get_alerts_target_indices.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 { ALERTS_TARGET_INDICES_URL } from '../../constants'; +import { GetService } from '../../types'; +import { User } from '../authentication/types'; +import { getSpaceUrlPrefix } from '../authentication/spaces'; + +export const getAlertsTargetIndices = async ( + getService: GetService, + user: User, + spaceId: string +) => { + const supertest = getService('supertestWithoutAuth'); + return supertest + .get(`${getSpaceUrlPrefix(spaceId)}${ALERTS_TARGET_INDICES_URL}`) + .auth(user.username, user.password) + .send() + .set('kbn-xsrf', 'foo'); +}; diff --git a/x-pack/test/rule_registry/common/lib/helpers/index.ts b/x-pack/test/rule_registry/common/lib/helpers/index.ts new file mode 100644 index 00000000000000..695f71021d5ab9 --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/helpers/index.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. + */ + +export * from './create_alert'; +export * from './create_apm_metric_index'; +export * from './create_transaction_metric'; +export * from './get_alerts_target_indices'; +export * from './wait_until_next_execution'; +export * from './cleanup_target_indices'; +export * from './delete_alert'; diff --git a/x-pack/test/rule_registry/common/lib/helpers/wait_until_next_execution.ts b/x-pack/test/rule_registry/common/lib/helpers/wait_until_next_execution.ts new file mode 100644 index 00000000000000..8bc325c4a6bb76 --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/helpers/wait_until_next_execution.ts @@ -0,0 +1,81 @@ +/* + * 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 { GetService } from '../../types'; +import { getAlertsTargetIndices } from './get_alerts_target_indices'; +import { BULK_INDEX_DELAY, MAX_POLLS } from '../../constants'; +import { Alert } from '../../../../../plugins/alerting/common'; +import { getSpaceUrlPrefix } from '../authentication/spaces'; +import { User } from '../authentication/types'; + +export async function waitUntilNextExecution( + getService: GetService, + user: User, + alert: Alert, + spaceId: string, + intervalInSeconds: number = 1, + count: number = 0 +): Promise { + const supertest = getService('supertestWithoutAuth'); + const es = getService('es'); + + await new Promise((resolve) => { + setTimeout(resolve, intervalInSeconds * 1000); + }); + + const { body, status } = await supertest + .get(`${getSpaceUrlPrefix(spaceId)}/api/alerts/alert/${alert.id}`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'foo'); + + const { body: targetIndices, status: targetIndicesStatus } = await getAlertsTargetIndices( + getService, + user, + spaceId + ); + if (targetIndices.length === 0) { + const error = new Error('Error getting target indices'); + Object.assign(error, { response: { body: targetIndices, status: targetIndicesStatus } }); + throw error; + } + + if (status >= 300) { + const error = new Error('Error getting alert'); + Object.assign(error, { response: { body, status } }); + throw error; + } + + const nextAlert = body as Alert; + + if (nextAlert.executionStatus.lastExecutionDate !== alert.executionStatus.lastExecutionDate) { + await new Promise((resolve) => { + setTimeout(resolve, BULK_INDEX_DELAY); + }); + + /** + * When calling refresh on an index pattern .alerts-observability.apm.alerts* (as was originally the hard-coded string in this test) + * The response from Elasticsearch is a 200, even if no indices which match that index pattern have been created. + * When calling refresh on a concrete index alias .alerts-observability.apm.alerts-default for instance, + * we receive a 404 error index_not_found_exception when no indices have been created which match that alias (obviously). + * Since we are receiving a concrete index alias from the observability api instead of a kibana index pattern + * and we understand / expect that this index does not exist at certain points of the test, we can try-catch at certain points without caring if the call fails. + * There are points in the code where we do want to ensure we get the appropriate error message back + */ + try { + await es.indices.refresh({ + index: targetIndices[0], + }); + // eslint-disable-next-line no-empty + } catch (exc) {} + return nextAlert; + } + + if (count >= MAX_POLLS) { + throw new Error('Maximum number of polls exceeded'); + } + + return waitUntilNextExecution(getService, user, alert, spaceId, intervalInSeconds, count + 1); +} diff --git a/x-pack/test/rule_registry/common/types.ts b/x-pack/test/rule_registry/common/types.ts new file mode 100644 index 00000000000000..63d45b0d850d9e --- /dev/null +++ b/x-pack/test/rule_registry/common/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { GenericFtrProviderContext } from '@kbn/test'; +import { Alert, AlertTypeParams } from '../../../plugins/alerting/common'; +import { services } from './services'; + +export type GetService = GenericFtrProviderContext['getService']; + +export interface AlertParams extends AlertTypeParams { + windowSize?: number; + windowUnit?: string; + threshold?: number; + serviceName?: string; + transactionType?: string; + environment?: string; +} + +export type AlertDef = Partial>; diff --git a/x-pack/test/rule_registry/spaces_only/config_basic.ts b/x-pack/test/rule_registry/spaces_only/config_basic.ts new file mode 100644 index 00000000000000..5a2ee4c1c11783 --- /dev/null +++ b/x-pack/test/rule_registry/spaces_only/config_basic.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 { createTestConfig } from '../common/config'; + +// eslint-disable-next-line import/no-default-export +export default createTestConfig('spaces_only', { + license: 'basic', + disabledPlugins: ['security'], + ssl: false, + testFiles: [require.resolve('./tests/basic')], +}); diff --git a/x-pack/test/rule_registry/spaces_only/tests/basic/bootstrap.ts b/x-pack/test/rule_registry/spaces_only/tests/basic/bootstrap.ts new file mode 100644 index 00000000000000..ccae5189f6d30c --- /dev/null +++ b/x-pack/test/rule_registry/spaces_only/tests/basic/bootstrap.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import type { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { obsOnlyRead } from '../../../common/lib/authentication/users'; +import { getAlertsTargetIndices } from '../../../common/lib/helpers'; + +// eslint-disable-next-line import/no-default-export +export default function registryRulesApiTest({ getService }: FtrProviderContext) { + const es = getService('es'); + + describe('Rule Registry API', () => { + describe('with read permissions', () => { + it('does not bootstrap the apm rule indices', async () => { + const { body: targetIndices } = await getAlertsTargetIndices( + getService, + obsOnlyRead, + 'space1' + ); + const errorOrUndefined = await es.indices + .get({ + index: targetIndices[0], + expand_wildcards: 'open', + allow_no_indices: false, + }) + .then(() => {}) + .catch((error) => { + return error.toString(); + }); + + expect(errorOrUndefined).not.to.be(undefined); + + expect(errorOrUndefined).to.contain('index_not_found_exception'); + }); + }); + }); +} diff --git a/x-pack/test/rule_registry/spaces_only/tests/basic/index.ts b/x-pack/test/rule_registry/spaces_only/tests/basic/index.ts new file mode 100644 index 00000000000000..aeb2b085ad3796 --- /dev/null +++ b/x-pack/test/rule_registry/spaces_only/tests/basic/index.ts @@ -0,0 +1,28 @@ +/* + * 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 { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { createSpaces, deleteSpaces } from '../../../common/lib/authentication'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile, getService }: FtrProviderContext): void => { + describe('rule registry spaces only: trial', function () { + // Fastest ciGroup for the moment. + this.tags('ciGroup5'); + + before(async () => { + await createSpaces(getService); + }); + + after(async () => { + await deleteSpaces(getService); + }); + + // Basic + loadTestFile(require.resolve('./bootstrap')); + }); +}; diff --git a/x-pack/test/rule_registry/spaces_only/tests/trial/__snapshots__/create_rule.snap b/x-pack/test/rule_registry/spaces_only/tests/trial/__snapshots__/create_rule.snap new file mode 100644 index 00000000000000..cf0b942faa5f84 --- /dev/null +++ b/x-pack/test/rule_registry/spaces_only/tests/trial/__snapshots__/create_rule.snap @@ -0,0 +1,124 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`rule registry spaces only: trial Rule Registry API with write permissions when creating a rule writes alerts data to the alert indices 1`] = ` +Object { + "event.action": Array [ + "open", + ], + "event.kind": Array [ + "signal", + ], + "kibana.alert.duration.us": Array [ + 0, + ], + "kibana.alert.evaluation.threshold": Array [ + 30, + ], + "kibana.alert.evaluation.value": Array [ + 50, + ], + "kibana.alert.instance.id": Array [ + "apm.transaction_error_rate_opbeans-go_request_ENVIRONMENT_NOT_DEFINED", + ], + "kibana.alert.reason": Array [ + "Failed transactions rate is greater than 30% (current value is 50%) for opbeans-go", + ], + "kibana.alert.rule.category": Array [ + "Failed transaction rate threshold", + ], + "kibana.alert.rule.consumer": Array [ + "apm", + ], + "kibana.alert.rule.name": Array [ + "Failed transaction rate threshold | opbeans-go", + ], + "kibana.alert.rule.producer": Array [ + "apm", + ], + "kibana.alert.rule.rule_type_id": Array [ + "apm.transaction_error_rate", + ], + "kibana.alert.status": Array [ + "active", + ], + "kibana.alert.workflow_status": Array [ + "open", + ], + "kibana.space_ids": Array [ + "space1", + ], + "processor.event": Array [ + "transaction", + ], + "service.name": Array [ + "opbeans-go", + ], + "tags": Array [ + "apm", + "service.name:opbeans-go", + ], + "transaction.type": Array [ + "request", + ], +} +`; + +exports[`rule registry spaces only: trial Rule Registry API with write permissions when creating a rule writes alerts data to the alert indices 2`] = ` +Object { + "event.action": Array [ + "close", + ], + "event.kind": Array [ + "signal", + ], + "kibana.alert.evaluation.threshold": Array [ + 30, + ], + "kibana.alert.evaluation.value": Array [ + 50, + ], + "kibana.alert.instance.id": Array [ + "apm.transaction_error_rate_opbeans-go_request_ENVIRONMENT_NOT_DEFINED", + ], + "kibana.alert.reason": Array [ + "Failed transactions rate is greater than 30% (current value is 50%) for opbeans-go", + ], + "kibana.alert.rule.category": Array [ + "Failed transaction rate threshold", + ], + "kibana.alert.rule.consumer": Array [ + "apm", + ], + "kibana.alert.rule.name": Array [ + "Failed transaction rate threshold | opbeans-go", + ], + "kibana.alert.rule.producer": Array [ + "apm", + ], + "kibana.alert.rule.rule_type_id": Array [ + "apm.transaction_error_rate", + ], + "kibana.alert.status": Array [ + "recovered", + ], + "kibana.alert.workflow_status": Array [ + "open", + ], + "kibana.space_ids": Array [ + "space1", + ], + "processor.event": Array [ + "transaction", + ], + "service.name": Array [ + "opbeans-go", + ], + "tags": Array [ + "apm", + "service.name:opbeans-go", + ], + "transaction.type": Array [ + "request", + ], +} +`; diff --git a/x-pack/test/rule_registry/spaces_only/tests/trial/create_rule.ts b/x-pack/test/rule_registry/spaces_only/tests/trial/create_rule.ts new file mode 100644 index 00000000000000..ac36bad1f595ba --- /dev/null +++ b/x-pack/test/rule_registry/spaces_only/tests/trial/create_rule.ts @@ -0,0 +1,252 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { + ALERT_DURATION, + ALERT_END, + ALERT_RULE_UUID, + ALERT_START, + ALERT_STATUS, + ALERT_UUID, + EVENT_KIND, + VERSION, +} from '@kbn/rule-data-utils'; +import { omit } from 'lodash'; +import type { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + getAlertsTargetIndices, + createApmMetricIndex, + createAlert, + waitUntilNextExecution, + createTransactionMetric, + cleanupTargetIndices, + deleteAlert, +} from '../../../common/lib/helpers'; +import { AlertDef, AlertParams } from '../../../common/types'; +import { Alert } from '../../../../../plugins/alerting/common'; +import { APM_METRIC_INDEX_NAME } from '../../../common/constants'; +import { obsOnly } from '../../../common/lib/authentication/users'; + +const SPACE_ID = 'space1'; + +// eslint-disable-next-line import/no-default-export +export default function registryRulesApiTest({ getService }: FtrProviderContext) { + const es = getService('es'); + + describe('Rule Registry API', () => { + describe('with write permissions', () => { + it('does not bootstrap indices on plugin startup', async () => { + const { body: targetIndices } = await getAlertsTargetIndices(getService, obsOnly, SPACE_ID); + try { + const res = await es.indices.get({ + index: targetIndices[0], + expand_wildcards: 'open', + allow_no_indices: true, + }); + expect(res).to.be.empty(); + } catch (exc) { + expect(exc.statusCode).to.eql(404); + } + }); + + describe('when creating a rule', () => { + let createResponse: { + alert: Alert; + status: number; + }; + before(async () => { + await createApmMetricIndex(getService); + const alertDef: AlertDef = { + params: { + threshold: 30, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + environment: 'ENVIRONMENT_ALL', + serviceName: 'opbeans-go', + }, + consumer: 'apm', + alertTypeId: 'apm.transaction_error_rate', + schedule: { interval: '5s' }, + actions: [], + tags: ['apm', 'service.name:opbeans-go'], + notifyWhen: 'onActionGroupChange', + name: 'Failed transaction rate threshold | opbeans-go', + }; + createResponse = await createAlert(getService, obsOnly, SPACE_ID, alertDef); + }); + after(async () => { + await deleteAlert(getService, obsOnly, SPACE_ID, createResponse.alert.id); + await cleanupTargetIndices(getService, obsOnly, SPACE_ID); + }); + + it('writes alerts data to the alert indices', async () => { + expect(createResponse.status).to.be.below(299); + + expect(createResponse.alert).not.to.be(undefined); + let alert = await waitUntilNextExecution( + getService, + obsOnly, + createResponse.alert, + SPACE_ID + ); + + const { body: targetIndices } = await getAlertsTargetIndices( + getService, + obsOnly, + SPACE_ID + ); + + try { + const res = await es.search({ + index: targetIndices[0], + body: { + query: { + term: { + [EVENT_KIND]: 'signal', + }, + }, + size: 1, + sort: { + '@timestamp': 'desc', + }, + }, + }); + expect(res).to.be.empty(); + } catch (exc) { + expect(exc.message).contain('index_not_found_exception'); + } + + await es.index({ + index: APM_METRIC_INDEX_NAME, + body: createTransactionMetric({ + event: { + outcome: 'success', + }, + }), + refresh: true, + }); + + alert = await waitUntilNextExecution(getService, obsOnly, alert, SPACE_ID); + + try { + const res = await es.search({ + index: targetIndices[0], + body: { + query: { + term: { + [EVENT_KIND]: 'signal', + }, + }, + size: 1, + sort: { + '@timestamp': 'desc', + }, + }, + }); + expect(res).to.be.empty(); + } catch (exc) { + expect(exc.message).contain('index_not_found_exception'); + } + + await es.index({ + index: APM_METRIC_INDEX_NAME, + body: createTransactionMetric({ + event: { + outcome: 'failure', + }, + }), + refresh: true, + }); + + alert = await waitUntilNextExecution(getService, obsOnly, alert, SPACE_ID); + + const afterViolatingDataResponse = await es.search({ + index: targetIndices[0], + body: { + query: { + term: { + [EVENT_KIND]: 'signal', + }, + }, + size: 1, + sort: { + '@timestamp': 'desc', + }, + _source: false, + fields: [{ field: '*', include_unmapped: true }], + }, + }); + + expect(afterViolatingDataResponse.hits.hits.length).to.be(1); + + const alertEvent = afterViolatingDataResponse.hits.hits[0].fields as Record; + + const exclude = ['@timestamp', ALERT_START, ALERT_UUID, ALERT_RULE_UUID, VERSION]; + + const toCompare = omit(alertEvent, exclude); + + expectSnapshot(toCompare).toMatch(); + + await es.bulk({ + index: APM_METRIC_INDEX_NAME, + body: [ + { index: {} }, + createTransactionMetric({ + event: { + outcome: 'success', + }, + }), + { index: {} }, + createTransactionMetric({ + event: { + outcome: 'success', + }, + }), + ], + refresh: true, + }); + + alert = await waitUntilNextExecution(getService, obsOnly, alert, SPACE_ID); + + const afterRecoveryResponse = await es.search({ + index: targetIndices[0], + body: { + query: { + term: { + [EVENT_KIND]: 'signal', + }, + }, + size: 1, + sort: { + '@timestamp': 'desc', + }, + _source: false, + fields: [{ field: '*', include_unmapped: true }], + }, + }); + + expect(afterRecoveryResponse.hits.hits.length).to.be(1); + + const recoveredAlertEvent = afterRecoveryResponse.hits.hits[0].fields as Record< + string, + any + >; + + expect(recoveredAlertEvent[ALERT_STATUS]?.[0]).to.eql('recovered'); + expect(recoveredAlertEvent[ALERT_DURATION]?.[0]).to.be.greaterThan(0); + expect(new Date(recoveredAlertEvent[ALERT_END]?.[0]).getTime()).to.be.greaterThan(0); + + expectSnapshot( + omit(recoveredAlertEvent, exclude.concat([ALERT_DURATION, ALERT_END])) + ).toMatch(); + }); + }); + }); + }); +} diff --git a/x-pack/test/rule_registry/spaces_only/tests/trial/index.ts b/x-pack/test/rule_registry/spaces_only/tests/trial/index.ts index 6deba4c68d0e26..c8fc677eb06704 100644 --- a/x-pack/test/rule_registry/spaces_only/tests/trial/index.ts +++ b/x-pack/test/rule_registry/spaces_only/tests/trial/index.ts @@ -22,8 +22,9 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => { await deleteSpaces(getService); }); - // Basic + // Trial loadTestFile(require.resolve('./get_alert_by_id')); loadTestFile(require.resolve('./update_alert')); + loadTestFile(require.resolve('./create_rule')); }); };