;
-// @public @deprecated (undocumented)
-export interface LegacyRequest extends Request {
-}
-
// Warning: (ae-forgotten-export) The symbol "lifecycleResponseFactory" needs to be exported by the entry point index.d.ts
//
// @public
@@ -1607,8 +1603,6 @@ export interface RegisterDeprecationsConfig {
// @public
export type RequestHandler = (context: Context, request: KibanaRequest
, response: ResponseFactory) => IKibanaResponse | Promise>;
-// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "LegacyScopedClusterClient"
-//
// @public
export interface RequestHandlerContext {
// (undocumented)
@@ -2719,7 +2713,7 @@ export class SavedObjectTypeRegistry {
export type SavedObjectUnsanitizedDoc = SavedObjectDoc & Partial;
// @public
-export type ScopeableRequest = KibanaRequest | LegacyRequest | FakeRequest;
+export type ScopeableRequest = KibanaRequest | FakeRequest;
// @public (undocumented)
export interface SearchResponse {
diff --git a/src/dev/build/tasks/create_empty_dirs_and_files_task.ts b/src/dev/build/tasks/create_empty_dirs_and_files_task.ts
index 6ef878cbab554..06b402c580151 100644
--- a/src/dev/build/tasks/create_empty_dirs_and_files_task.ts
+++ b/src/dev/build/tasks/create_empty_dirs_and_files_task.ts
@@ -12,6 +12,9 @@ export const CreateEmptyDirsAndFiles: Task = {
description: 'Creating some empty directories and files to prevent file-permission issues',
async run(config, log, build) {
- await mkdirp(build.resolvePath('plugins'));
+ await Promise.all([
+ mkdirp(build.resolvePath('plugins')),
+ mkdirp(build.resolvePath('data/optimize')),
+ ]);
},
};
diff --git a/src/dev/ensure_all_tests_in_ci_group.ts b/src/dev/ensure_all_tests_in_ci_group.ts
new file mode 100644
index 0000000000000..aeccefae05d2c
--- /dev/null
+++ b/src/dev/ensure_all_tests_in_ci_group.ts
@@ -0,0 +1,52 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import Path from 'path';
+import Fs from 'fs/promises';
+
+import execa from 'execa';
+import { safeLoad } from 'js-yaml';
+
+import { run, REPO_ROOT } from '@kbn/dev-utils';
+import { schema } from '@kbn/config-schema';
+
+const RELATIVE_JOBS_YAML_PATH = '.ci/ci_groups.yml';
+const JOBS_YAML_PATH = Path.resolve(REPO_ROOT, RELATIVE_JOBS_YAML_PATH);
+const SCHEMA = schema.object({
+ root: schema.arrayOf(schema.string()),
+ xpack: schema.arrayOf(schema.string()),
+});
+
+export function runEnsureAllTestsInCiGroupsCli() {
+ run(async ({ log }) => {
+ const { root, xpack } = SCHEMA.validate(safeLoad(await Fs.readFile(JOBS_YAML_PATH, 'utf-8')));
+
+ log.info(
+ 'validating root tests directory contains all "root" ciGroups from',
+ RELATIVE_JOBS_YAML_PATH
+ );
+ await execa(process.execPath, [
+ 'scripts/functional_tests',
+ ...root.map((tag) => `--include-tag=${tag}`),
+ '--include-tag=runOutsideOfCiGroups',
+ '--assert-none-excluded',
+ ]);
+
+ log.info(
+ 'validating x-pack/tests directory contains all "xpack" ciGroups from',
+ RELATIVE_JOBS_YAML_PATH
+ );
+ await execa(process.execPath, [
+ 'x-pack/scripts/functional_tests',
+ ...xpack.map((tag) => `--include-tag=${tag}`),
+ '--assert-none-excluded',
+ ]);
+
+ log.success('all tests are in a valid ciGroup');
+ });
+}
diff --git a/src/dev/run_ensure_all_tests_in_ci_group.js b/src/dev/run_ensure_all_tests_in_ci_group.js
deleted file mode 100644
index b5e4798d8d800..0000000000000
--- a/src/dev/run_ensure_all_tests_in_ci_group.js
+++ /dev/null
@@ -1,50 +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 and the Server Side Public License, v 1; you may not use this file except
- * in compliance with, at your election, the Elastic License 2.0 or the Server
- * Side Public License, v 1.
- */
-
-import { readFileSync } from 'fs';
-import { resolve } from 'path';
-
-import execa from 'execa';
-import { safeLoad } from 'js-yaml';
-
-import { run } from '@kbn/dev-utils';
-
-const JOBS_YAML = readFileSync(resolve(__dirname, '../../.ci/jobs.yml'), 'utf8');
-const TEST_TAGS = safeLoad(JOBS_YAML)
- .JOB.filter((id) => id.startsWith('kibana-ciGroup'))
- .map((id) => id.replace(/^kibana-/, ''));
-
-run(async ({ log }) => {
- try {
- const result = await execa(process.execPath, [
- 'scripts/functional_test_runner',
- ...TEST_TAGS.map((tag) => `--include-tag=${tag}`),
- '--config',
- 'test/functional/config.js',
- '--test-stats',
- ]);
- const stats = JSON.parse(result.stderr);
-
- if (stats.excludedTests.length > 0) {
- log.error(`
- ${stats.excludedTests.length} tests are excluded by the ciGroup tags, make sure that
- all test suites have a "ciGroup{X}" tag and that "tasks/functional_test_groups.js"
- knows about the tag that you are using.
-
- tags: ${JSON.stringify({ include: TEST_TAGS })}
-
- - ${stats.excludedTests.join('\n - ')}
- `);
- process.exitCode = 1;
- return;
- }
- } catch (error) {
- log.error(error.stack);
- process.exitCode = 1;
- }
-});
diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx
index 1352081eaa30b..f29ab120013c6 100644
--- a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx
+++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx
@@ -9,7 +9,7 @@
import { HttpSetup } from 'kibana/public';
import React, { createContext, useContext } from 'react';
-import { useRequest } from '../../../public';
+import { useRequest } from '../../../public/request';
import { Privileges, Error as CustomError } from '../types';
diff --git a/src/plugins/es_ui_shared/public/components/code_editor/code_editor.tsx b/src/plugins/es_ui_shared/public/components/code_editor/code_editor.tsx
index cae3210857543..6299b473f68df 100644
--- a/src/plugins/es_ui_shared/public/components/code_editor/code_editor.tsx
+++ b/src/plugins/es_ui_shared/public/components/code_editor/code_editor.tsx
@@ -46,7 +46,7 @@ export interface EuiCodeEditorProps extends SupportedAriaAttributes, Omit {
static defaultProps = {
- setOptions: {},
+ setOptions: {
+ showLineNumbers: false,
+ tabSize: 2,
+ },
};
state: EuiCodeEditorState = {
diff --git a/src/plugins/interactive_setup/common/elasticsearch_connection_status.ts b/src/plugins/interactive_setup/common/elasticsearch_connection_status.ts
index 4e1506f69990c..bc0b172dfe234 100644
--- a/src/plugins/interactive_setup/common/elasticsearch_connection_status.ts
+++ b/src/plugins/interactive_setup/common/elasticsearch_connection_status.ts
@@ -10,11 +10,6 @@
* Describes current status of the Elasticsearch connection.
*/
export enum ElasticsearchConnectionStatus {
- /**
- * Indicates that Kibana hasn't figured out yet if existing Elasticsearch connection configuration is valid.
- */
- Unknown = 'unknown',
-
/**
* Indicates that current Elasticsearch connection configuration valid and sufficient.
*/
diff --git a/src/plugins/interactive_setup/server/config.test.ts b/src/plugins/interactive_setup/server/config.test.ts
new file mode 100644
index 0000000000000..b8ae673ad28f9
--- /dev/null
+++ b/src/plugins/interactive_setup/server/config.test.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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { ConfigSchema } from './config';
+
+describe('config schema', () => {
+ it('generates proper defaults', () => {
+ expect(ConfigSchema.validate({})).toMatchInlineSnapshot(`
+ Object {
+ "connectionCheck": Object {
+ "interval": "PT5S",
+ },
+ "enabled": false,
+ }
+ `);
+ });
+
+ describe('#connectionCheck', () => {
+ it('should properly set required connection check interval', () => {
+ expect(ConfigSchema.validate({ connectionCheck: { interval: '1s' } })).toMatchInlineSnapshot(`
+ Object {
+ "connectionCheck": Object {
+ "interval": "PT1S",
+ },
+ "enabled": false,
+ }
+ `);
+ });
+
+ it('should throw error if interactiveSetup.connectionCheck.interval is less than 1 second', () => {
+ expect(() =>
+ ConfigSchema.validate({ connectionCheck: { interval: 100 } })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[connectionCheck.interval]: the value must be greater or equal to 1 second."`
+ );
+ });
+ });
+});
diff --git a/src/plugins/interactive_setup/server/config.ts b/src/plugins/interactive_setup/server/config.ts
index b16c51bcbda09..9986f16e9ce93 100644
--- a/src/plugins/interactive_setup/server/config.ts
+++ b/src/plugins/interactive_setup/server/config.ts
@@ -13,4 +13,14 @@ export type ConfigType = TypeOf;
export const ConfigSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
+ connectionCheck: schema.object({
+ interval: schema.duration({
+ defaultValue: '5s',
+ validate(value) {
+ if (value.asSeconds() < 1) {
+ return 'the value must be greater or equal to 1 second.';
+ }
+ },
+ }),
+ }),
});
diff --git a/src/plugins/interactive_setup/server/elasticsearch_service.mock.ts b/src/plugins/interactive_setup/server/elasticsearch_service.mock.ts
new file mode 100644
index 0000000000000..8bc7e4307e76f
--- /dev/null
+++ b/src/plugins/interactive_setup/server/elasticsearch_service.mock.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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { BehaviorSubject } from 'rxjs';
+
+import { ElasticsearchConnectionStatus } from '../common';
+
+export const elasticsearchServiceMock = {
+ createSetup: () => ({
+ connectionStatus$: new BehaviorSubject(
+ ElasticsearchConnectionStatus.Configured
+ ),
+ enroll: jest.fn(),
+ }),
+};
diff --git a/src/plugins/interactive_setup/server/elasticsearch_service.test.ts b/src/plugins/interactive_setup/server/elasticsearch_service.test.ts
new file mode 100644
index 0000000000000..b8eb7293fd678
--- /dev/null
+++ b/src/plugins/interactive_setup/server/elasticsearch_service.test.ts
@@ -0,0 +1,497 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { errors } from '@elastic/elasticsearch';
+
+import { nextTick } from '@kbn/test/jest';
+import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks';
+
+import { ElasticsearchConnectionStatus } from '../common';
+import { ConfigSchema } from './config';
+import type { ElasticsearchServiceSetup } from './elasticsearch_service';
+import { ElasticsearchService } from './elasticsearch_service';
+import { interactiveSetupMock } from './mocks';
+
+describe('ElasticsearchService', () => {
+ let service: ElasticsearchService;
+ let mockElasticsearchPreboot: ReturnType;
+ beforeEach(() => {
+ service = new ElasticsearchService(loggingSystemMock.createLogger());
+ mockElasticsearchPreboot = elasticsearchServiceMock.createPreboot();
+ });
+
+ describe('#setup()', () => {
+ let mockConnectionStatusClient: ReturnType<
+ typeof elasticsearchServiceMock.createCustomClusterClient
+ >;
+ let mockEnrollClient: ReturnType;
+ let mockAuthenticateClient: ReturnType<
+ typeof elasticsearchServiceMock.createCustomClusterClient
+ >;
+ let setupContract: ElasticsearchServiceSetup;
+ beforeEach(() => {
+ mockConnectionStatusClient = elasticsearchServiceMock.createCustomClusterClient();
+ mockEnrollClient = elasticsearchServiceMock.createCustomClusterClient();
+ mockAuthenticateClient = elasticsearchServiceMock.createCustomClusterClient();
+ mockElasticsearchPreboot.createClient.mockImplementation((type) => {
+ switch (type) {
+ case 'enroll':
+ return mockEnrollClient;
+ case 'authenticate':
+ return mockAuthenticateClient;
+ default:
+ return mockConnectionStatusClient;
+ }
+ });
+
+ setupContract = service.setup({
+ elasticsearch: mockElasticsearchPreboot,
+ connectionCheckInterval: ConfigSchema.validate({}).connectionCheck.interval,
+ });
+ });
+
+ describe('#connectionStatus$', () => {
+ beforeEach(() => jest.useFakeTimers());
+ afterEach(() => jest.useRealTimers());
+
+ it('does not repeat ping request if have multiple subscriptions', async () => {
+ mockConnectionStatusClient.asInternalUser.ping.mockRejectedValue(
+ new errors.ConnectionError(
+ 'some-message',
+ interactiveSetupMock.createApiResponse({ body: {} })
+ )
+ );
+
+ const mockHandler1 = jest.fn();
+ const mockHandler2 = jest.fn();
+ setupContract.connectionStatus$.subscribe(mockHandler1);
+ setupContract.connectionStatus$.subscribe(mockHandler2);
+
+ jest.advanceTimersByTime(0);
+ await nextTick();
+
+ // Late subscription.
+ const mockHandler3 = jest.fn();
+ setupContract.connectionStatus$.subscribe(mockHandler3);
+
+ jest.advanceTimersByTime(100);
+ await nextTick();
+
+ expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(1);
+ expect(mockHandler1).toHaveBeenCalledTimes(1);
+ expect(mockHandler1).toHaveBeenCalledWith(ElasticsearchConnectionStatus.NotConfigured);
+ expect(mockHandler2).toHaveBeenCalledTimes(1);
+ expect(mockHandler2).toHaveBeenCalledWith(ElasticsearchConnectionStatus.NotConfigured);
+ expect(mockHandler3).toHaveBeenCalledTimes(1);
+ expect(mockHandler3).toHaveBeenCalledWith(ElasticsearchConnectionStatus.NotConfigured);
+ });
+
+ it('does not report the same status twice', async () => {
+ mockConnectionStatusClient.asInternalUser.ping.mockRejectedValue(
+ new errors.ConnectionError(
+ 'some-message',
+ interactiveSetupMock.createApiResponse({ body: {} })
+ )
+ );
+
+ const mockHandler = jest.fn();
+ setupContract.connectionStatus$.subscribe(mockHandler);
+
+ jest.advanceTimersByTime(0);
+ await nextTick();
+
+ expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(1);
+ expect(mockHandler).toHaveBeenCalledTimes(1);
+ expect(mockHandler).toHaveBeenCalledWith(ElasticsearchConnectionStatus.NotConfigured);
+
+ mockHandler.mockClear();
+
+ jest.advanceTimersByTime(5000);
+ await nextTick();
+
+ expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(2);
+ expect(mockHandler).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(5000);
+ await nextTick();
+
+ expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(3);
+ expect(mockHandler).not.toHaveBeenCalled();
+ });
+
+ it('stops status checks as soon as connection is known to be configured', async () => {
+ mockConnectionStatusClient.asInternalUser.ping.mockRejectedValue(
+ new errors.ConnectionError(
+ 'some-message',
+ interactiveSetupMock.createApiResponse({ body: {} })
+ )
+ );
+
+ const mockHandler = jest.fn();
+ setupContract.connectionStatus$.subscribe(mockHandler);
+
+ jest.advanceTimersByTime(0);
+ await nextTick();
+
+ // Initial ping (connection error).
+ expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(1);
+ expect(mockHandler).toHaveBeenCalledTimes(1);
+ expect(mockHandler).toHaveBeenCalledWith(ElasticsearchConnectionStatus.NotConfigured);
+
+ // Repeated ping (Unauthorized error).
+ mockConnectionStatusClient.asInternalUser.ping.mockRejectedValue(
+ new errors.ResponseError(
+ interactiveSetupMock.createApiResponse({ statusCode: 401, body: {} })
+ )
+ );
+ jest.advanceTimersByTime(5000);
+ await nextTick();
+
+ expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(2);
+ expect(mockHandler).toHaveBeenCalledTimes(2);
+ expect(mockHandler).toHaveBeenCalledWith(ElasticsearchConnectionStatus.Configured);
+
+ mockHandler.mockClear();
+ mockConnectionStatusClient.asInternalUser.ping.mockClear();
+
+ jest.advanceTimersByTime(5000);
+ await nextTick();
+
+ expect(mockConnectionStatusClient.asInternalUser.ping).not.toHaveBeenCalled();
+ expect(mockHandler).not.toHaveBeenCalled();
+ });
+
+ it('checks connection status only once if connection is known to be configured right from start', async () => {
+ mockConnectionStatusClient.asInternalUser.ping.mockResolvedValue(
+ interactiveSetupMock.createApiResponse({ body: true })
+ );
+
+ const mockHandler = jest.fn();
+ setupContract.connectionStatus$.subscribe(mockHandler);
+
+ jest.advanceTimersByTime(0);
+ await nextTick();
+
+ // Initial ping (connection error).
+ expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(1);
+ expect(mockHandler).toHaveBeenCalledTimes(1);
+ expect(mockHandler).toHaveBeenCalledWith(ElasticsearchConnectionStatus.Configured);
+
+ mockHandler.mockClear();
+ mockConnectionStatusClient.asInternalUser.ping.mockClear();
+
+ jest.advanceTimersByTime(5000);
+ await nextTick();
+
+ expect(mockConnectionStatusClient.asInternalUser.ping).not.toHaveBeenCalled();
+ expect(mockHandler).not.toHaveBeenCalled();
+
+ const mockHandler2 = jest.fn();
+ setupContract.connectionStatus$.subscribe(mockHandler2);
+
+ // Source observable is complete, and handler should be called immediately.
+ expect(mockHandler2).toHaveBeenCalledTimes(1);
+ expect(mockHandler2).toHaveBeenCalledWith(ElasticsearchConnectionStatus.Configured);
+
+ mockHandler2.mockClear();
+
+ // No status check should be made after the first attempt.
+ jest.advanceTimersByTime(5000);
+ await nextTick();
+
+ expect(mockConnectionStatusClient.asInternalUser.ping).not.toHaveBeenCalled();
+ expect(mockHandler).not.toHaveBeenCalled();
+ expect(mockHandler2).not.toHaveBeenCalled();
+ });
+
+ it('does not check connection status if there are no subscribers', async () => {
+ mockConnectionStatusClient.asInternalUser.ping.mockRejectedValue(
+ new errors.ConnectionError(
+ 'some-message',
+ interactiveSetupMock.createApiResponse({ body: {} })
+ )
+ );
+
+ const mockHandler = jest.fn();
+ const mockSubscription = setupContract.connectionStatus$.subscribe(mockHandler);
+
+ jest.advanceTimersByTime(0);
+ await nextTick();
+
+ expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(1);
+ expect(mockHandler).toHaveBeenCalledTimes(1);
+ expect(mockHandler).toHaveBeenCalledWith(ElasticsearchConnectionStatus.NotConfigured);
+
+ mockSubscription.unsubscribe();
+ mockHandler.mockClear();
+ mockConnectionStatusClient.asInternalUser.ping.mockClear();
+
+ jest.advanceTimersByTime(5000);
+ await nextTick();
+
+ expect(mockConnectionStatusClient.asInternalUser.ping).not.toHaveBeenCalled();
+ expect(mockHandler).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(5000);
+ await nextTick();
+
+ expect(mockConnectionStatusClient.asInternalUser.ping).not.toHaveBeenCalled();
+ expect(mockHandler).not.toHaveBeenCalled();
+ });
+
+ it('treats non-connection errors the same as successful response', async () => {
+ mockConnectionStatusClient.asInternalUser.ping.mockRejectedValue(
+ new errors.ResponseError(
+ interactiveSetupMock.createApiResponse({ statusCode: 401, body: {} })
+ )
+ );
+
+ const mockHandler = jest.fn();
+ setupContract.connectionStatus$.subscribe(mockHandler);
+
+ jest.advanceTimersByTime(0);
+ await nextTick();
+
+ expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(1);
+ expect(mockHandler).toHaveBeenCalledTimes(1);
+ expect(mockHandler).toHaveBeenCalledWith(ElasticsearchConnectionStatus.Configured);
+
+ mockHandler.mockClear();
+ mockConnectionStatusClient.asInternalUser.ping.mockClear();
+
+ jest.advanceTimersByTime(5000);
+ await nextTick();
+
+ expect(mockConnectionStatusClient.asInternalUser.ping).not.toHaveBeenCalled();
+ expect(mockHandler).not.toHaveBeenCalled();
+ });
+
+ it('treats product check error the same as successful response', async () => {
+ mockConnectionStatusClient.asInternalUser.ping.mockRejectedValue(
+ new errors.ProductNotSupportedError(interactiveSetupMock.createApiResponse({ body: {} }))
+ );
+
+ const mockHandler = jest.fn();
+ setupContract.connectionStatus$.subscribe(mockHandler);
+
+ jest.advanceTimersByTime(0);
+ await nextTick();
+
+ expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(1);
+ expect(mockHandler).toHaveBeenCalledTimes(1);
+ expect(mockHandler).toHaveBeenCalledWith(ElasticsearchConnectionStatus.Configured);
+
+ mockHandler.mockClear();
+ mockConnectionStatusClient.asInternalUser.ping.mockClear();
+
+ jest.advanceTimersByTime(5000);
+ await nextTick();
+
+ expect(mockConnectionStatusClient.asInternalUser.ping).not.toHaveBeenCalled();
+ expect(mockHandler).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('#enroll()', () => {
+ it('fails if enroll call fails', async () => {
+ const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
+ mockScopedClusterClient.asCurrentUser.transport.request.mockRejectedValue(
+ new errors.ResponseError(
+ interactiveSetupMock.createApiResponse({ statusCode: 401, body: { message: 'oh no' } })
+ )
+ );
+ mockEnrollClient.asScoped.mockReturnValue(mockScopedClusterClient);
+
+ await expect(
+ setupContract.enroll({ apiKey: 'apiKey', hosts: ['host1'] })
+ ).rejects.toMatchInlineSnapshot(`[ResponseError: {"message":"oh no"}]`);
+
+ expect(mockEnrollClient.asScoped).toHaveBeenCalledTimes(1);
+ expect(mockEnrollClient.close).toHaveBeenCalledTimes(1);
+ expect(mockAuthenticateClient.asInternalUser.security.authenticate).not.toHaveBeenCalled();
+ });
+
+ it('fails if none of the hosts are accessible', async () => {
+ const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
+ mockScopedClusterClient.asCurrentUser.transport.request.mockRejectedValue(
+ new errors.ConnectionError(
+ 'some-message',
+ interactiveSetupMock.createApiResponse({ body: {} })
+ )
+ );
+ mockEnrollClient.asScoped.mockReturnValue(mockScopedClusterClient);
+
+ await expect(
+ setupContract.enroll({ apiKey: 'apiKey', hosts: ['host1', 'host2'] })
+ ).rejects.toMatchInlineSnapshot(`[Error: Unable to connect to any of the provided hosts.]`);
+
+ expect(mockEnrollClient.close).toHaveBeenCalledTimes(2);
+ expect(mockAuthenticateClient.asInternalUser.security.authenticate).not.toHaveBeenCalled();
+ });
+
+ it('fails if authenticate call fails', async () => {
+ const mockEnrollScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
+ mockEnrollScopedClusterClient.asCurrentUser.transport.request.mockResolvedValue(
+ interactiveSetupMock.createApiResponse({
+ statusCode: 200,
+ body: { token: { name: 'some-name', value: 'some-value' }, http_ca: 'some-ca' },
+ })
+ );
+ mockEnrollClient.asScoped.mockReturnValue(mockEnrollScopedClusterClient);
+
+ mockAuthenticateClient.asInternalUser.security.authenticate.mockRejectedValue(
+ new errors.ResponseError(
+ interactiveSetupMock.createApiResponse({ statusCode: 401, body: { message: 'oh no' } })
+ )
+ );
+
+ await expect(
+ setupContract.enroll({ apiKey: 'apiKey', hosts: ['host1'] })
+ ).rejects.toMatchInlineSnapshot(`[ResponseError: {"message":"oh no"}]`);
+
+ expect(mockEnrollClient.asScoped).toHaveBeenCalledTimes(1);
+ expect(mockEnrollClient.close).toHaveBeenCalledTimes(1);
+ expect(mockAuthenticateClient.asInternalUser.security.authenticate).toHaveBeenCalledTimes(
+ 1
+ );
+ expect(mockAuthenticateClient.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('iterates through all provided hosts until find an accessible one', async () => {
+ mockElasticsearchPreboot.createClient.mockClear();
+
+ const mockHostOneEnrollScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
+ mockHostOneEnrollScopedClusterClient.asCurrentUser.transport.request.mockRejectedValue(
+ new errors.ConnectionError(
+ 'some-message',
+ interactiveSetupMock.createApiResponse({ body: {} })
+ )
+ );
+
+ const mockHostTwoEnrollScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
+ mockHostTwoEnrollScopedClusterClient.asCurrentUser.transport.request.mockResolvedValue(
+ interactiveSetupMock.createApiResponse({
+ statusCode: 200,
+ body: {
+ token: { name: 'some-name', value: 'some-value' },
+ http_ca: '\n\nsome weird-ca_with\n content\n\n',
+ },
+ })
+ );
+
+ mockEnrollClient.asScoped
+ .mockReturnValueOnce(mockHostOneEnrollScopedClusterClient)
+ .mockReturnValueOnce(mockHostTwoEnrollScopedClusterClient);
+
+ mockAuthenticateClient.asInternalUser.security.authenticate.mockResolvedValue(
+ interactiveSetupMock.createApiResponse({ statusCode: 200, body: {} as any })
+ );
+
+ const expectedCa = `-----BEGIN CERTIFICATE-----
+
+
+some weird+ca/with
+
+ content
+
+
+-----END CERTIFICATE-----
+`;
+
+ await expect(
+ setupContract.enroll({ apiKey: 'apiKey', hosts: ['host1', 'host2'] })
+ ).resolves.toEqual({
+ ca: expectedCa,
+ host: 'host2',
+ serviceAccountToken: {
+ name: 'some-name',
+ value: 'some-value',
+ },
+ });
+
+ // Check that we created clients with the right parameters
+ expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledTimes(3);
+ expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledWith('enroll', {
+ hosts: ['host1'],
+ ssl: { verificationMode: 'none' },
+ });
+ expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledWith('enroll', {
+ hosts: ['host2'],
+ ssl: { verificationMode: 'none' },
+ });
+ expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledWith('authenticate', {
+ hosts: ['host2'],
+ serviceAccountToken: 'some-value',
+ ssl: { certificateAuthorities: [expectedCa] },
+ });
+
+ // Check that we properly provided apiKeys to scoped clients.
+ expect(mockEnrollClient.asScoped).toHaveBeenCalledTimes(2);
+ expect(mockEnrollClient.asScoped).toHaveBeenNthCalledWith(1, {
+ headers: { authorization: 'ApiKey apiKey' },
+ });
+ expect(mockEnrollClient.asScoped).toHaveBeenNthCalledWith(2, {
+ headers: { authorization: 'ApiKey apiKey' },
+ });
+
+ // Check that we properly called all required ES APIs.
+ expect(
+ mockHostOneEnrollScopedClusterClient.asCurrentUser.transport.request
+ ).toHaveBeenCalledTimes(1);
+ expect(
+ mockHostOneEnrollScopedClusterClient.asCurrentUser.transport.request
+ ).toHaveBeenCalledWith({
+ method: 'GET',
+ path: '/_security/enroll/kibana',
+ });
+ expect(
+ mockHostTwoEnrollScopedClusterClient.asCurrentUser.transport.request
+ ).toHaveBeenCalledTimes(1);
+ expect(
+ mockHostTwoEnrollScopedClusterClient.asCurrentUser.transport.request
+ ).toHaveBeenCalledWith({
+ method: 'GET',
+ path: '/_security/enroll/kibana',
+ });
+ expect(mockAuthenticateClient.asInternalUser.security.authenticate).toHaveBeenCalledTimes(
+ 1
+ );
+
+ // Check that we properly closed all clients.
+ expect(mockEnrollClient.close).toHaveBeenCalledTimes(2);
+ expect(mockAuthenticateClient.close).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe('#stop()', () => {
+ it('does not fail if called before `setup`', () => {
+ expect(() => service.stop()).not.toThrow();
+ });
+
+ it('closes connection status check client', async () => {
+ const mockConnectionStatusClient = elasticsearchServiceMock.createCustomClusterClient();
+ mockElasticsearchPreboot.createClient.mockImplementation((type) => {
+ switch (type) {
+ case 'ping':
+ return mockConnectionStatusClient;
+ default:
+ throw new Error(`Unexpected client type: ${type}`);
+ }
+ });
+
+ service.setup({
+ elasticsearch: mockElasticsearchPreboot,
+ connectionCheckInterval: ConfigSchema.validate({}).connectionCheck.interval,
+ });
+ service.stop();
+
+ expect(mockConnectionStatusClient.close).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/plugins/interactive_setup/server/elasticsearch_service.ts b/src/plugins/interactive_setup/server/elasticsearch_service.ts
new file mode 100644
index 0000000000000..cad34e1a4d44a
--- /dev/null
+++ b/src/plugins/interactive_setup/server/elasticsearch_service.ts
@@ -0,0 +1,239 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import type { ApiResponse } from '@elastic/elasticsearch';
+import { errors } from '@elastic/elasticsearch';
+import type { Duration } from 'moment';
+import type { Observable } from 'rxjs';
+import { from, of, timer } from 'rxjs';
+import {
+ catchError,
+ distinctUntilChanged,
+ exhaustMap,
+ map,
+ shareReplay,
+ takeWhile,
+} from 'rxjs/operators';
+
+import type {
+ ElasticsearchClientConfig,
+ ElasticsearchServicePreboot,
+ ICustomClusterClient,
+ Logger,
+ ScopeableRequest,
+} from 'src/core/server';
+
+import { ElasticsearchConnectionStatus } from '../common';
+import { getDetailedErrorMessage } from './errors';
+
+interface EnrollParameters {
+ apiKey: string;
+ hosts: string[];
+ // TODO: Integrate fingerprint check as soon core supports this new option:
+ // https://github.com/elastic/kibana/pull/108514
+ caFingerprint?: string;
+}
+
+export interface ElasticsearchServiceSetupDeps {
+ /**
+ * Core Elasticsearch service preboot contract;
+ */
+ elasticsearch: ElasticsearchServicePreboot;
+
+ /**
+ * Interval for the Elasticsearch connection check (whether it's configured or not).
+ */
+ connectionCheckInterval: Duration;
+}
+
+export interface ElasticsearchServiceSetup {
+ /**
+ * Observable that yields the last result of the Elasticsearch connection status check.
+ */
+ connectionStatus$: Observable;
+
+ /**
+ * Iterates through provided {@param hosts} one by one trying to call Kibana enrollment API using
+ * the specified {@param apiKey}.
+ * @param apiKey The ApiKey to use to authenticate Kibana enrollment request.
+ * @param hosts The list of Elasticsearch node addresses to enroll with. The addresses are supposed
+ * to point to exactly same Elasticsearch node, potentially available via different network interfaces.
+ */
+ enroll: (params: EnrollParameters) => Promise;
+}
+
+/**
+ * Result of the enrollment request.
+ */
+export interface EnrollResult {
+ /**
+ * Host address of the Elasticsearch node that successfully processed enrollment request.
+ */
+ host: string;
+ /**
+ * PEM CA certificate for the Elasticsearch HTTP certificates.
+ */
+ ca: string;
+ /**
+ * Service account token for the "elastic/kibana" service account.
+ */
+ serviceAccountToken: { name: string; value: string };
+}
+
+export class ElasticsearchService {
+ /**
+ * Elasticsearch client used to check Elasticsearch connection status.
+ */
+ private connectionStatusClient?: ICustomClusterClient;
+ constructor(private readonly logger: Logger) {}
+
+ public setup({
+ elasticsearch,
+ connectionCheckInterval,
+ }: ElasticsearchServiceSetupDeps): ElasticsearchServiceSetup {
+ const connectionStatusClient = (this.connectionStatusClient = elasticsearch.createClient(
+ 'ping'
+ ));
+
+ return {
+ connectionStatus$: timer(0, connectionCheckInterval.asMilliseconds()).pipe(
+ exhaustMap(() => {
+ return from(connectionStatusClient.asInternalUser.ping()).pipe(
+ map(() => ElasticsearchConnectionStatus.Configured),
+ catchError((pingError) =>
+ of(
+ pingError instanceof errors.ConnectionError
+ ? ElasticsearchConnectionStatus.NotConfigured
+ : ElasticsearchConnectionStatus.Configured
+ )
+ )
+ );
+ }),
+ takeWhile(
+ (status) => status !== ElasticsearchConnectionStatus.Configured,
+ /* inclusive */ true
+ ),
+ distinctUntilChanged(),
+ shareReplay({ refCount: true, bufferSize: 1 })
+ ),
+ enroll: this.enroll.bind(this, elasticsearch),
+ };
+ }
+
+ public stop() {
+ if (this.connectionStatusClient) {
+ this.connectionStatusClient.close().catch((err) => {
+ this.logger.debug(`Failed to stop Elasticsearch service: ${getDetailedErrorMessage(err)}`);
+ });
+ this.connectionStatusClient = undefined;
+ }
+ }
+
+ /**
+ * Iterates through provided {@param hosts} one by one trying to call Kibana enrollment API using
+ * the specified {@param apiKey}.
+ * @param elasticsearch Core Elasticsearch service preboot contract.
+ * @param apiKey The ApiKey to use to authenticate Kibana enrollment request.
+ * @param hosts The list of Elasticsearch node addresses to enroll with. The addresses are supposed
+ * to point to exactly same Elasticsearch node, potentially available via different network interfaces.
+ */
+ private async enroll(
+ elasticsearch: ElasticsearchServicePreboot,
+ { apiKey, hosts }: EnrollParameters
+ ): Promise {
+ const scopeableRequest: ScopeableRequest = { headers: { authorization: `ApiKey ${apiKey}` } };
+ const elasticsearchConfig: Partial = {
+ ssl: { verificationMode: 'none' },
+ };
+
+ // We should iterate through all provided hosts until we find an accessible one.
+ for (const host of hosts) {
+ this.logger.debug(`Trying to enroll with "${host}" host`);
+ const enrollClient = elasticsearch.createClient('enroll', {
+ ...elasticsearchConfig,
+ hosts: [host],
+ });
+
+ let enrollmentResponse;
+ try {
+ enrollmentResponse = (await enrollClient
+ .asScoped(scopeableRequest)
+ .asCurrentUser.transport.request({
+ method: 'GET',
+ path: '/_security/enroll/kibana',
+ })) as ApiResponse<{ token: { name: string; value: string }; http_ca: string }>;
+ } catch (err) {
+ // We expect that all hosts belong to exactly same node and any non-connection error for one host would mean
+ // that enrollment will fail for any other host and we should bail out.
+ if (err instanceof errors.ConnectionError || err instanceof errors.TimeoutError) {
+ this.logger.error(
+ `Unable to connect to "${host}" host, will proceed to the next host if available: ${getDetailedErrorMessage(
+ err
+ )}`
+ );
+ continue;
+ }
+
+ this.logger.error(`Failed to enroll with "${host}" host: ${getDetailedErrorMessage(err)}`);
+ throw err;
+ } finally {
+ await enrollClient.close();
+ }
+
+ this.logger.debug(
+ `Successfully enrolled with "${host}" host, token name: ${enrollmentResponse.body.token.name}, CA certificate: ${enrollmentResponse.body.http_ca}`
+ );
+
+ const enrollResult = {
+ host,
+ ca: ElasticsearchService.createPemCertificate(enrollmentResponse.body.http_ca),
+ serviceAccountToken: enrollmentResponse.body.token,
+ };
+
+ // Now try to use retrieved password and CA certificate to authenticate to this host.
+ const authenticateClient = elasticsearch.createClient('authenticate', {
+ hosts: [host],
+ serviceAccountToken: enrollResult.serviceAccountToken.value,
+ ssl: { certificateAuthorities: [enrollResult.ca] },
+ });
+
+ this.logger.debug(
+ `Verifying if "${enrollmentResponse.body.token.name}" token can authenticate to "${host}" host.`
+ );
+
+ try {
+ await authenticateClient.asInternalUser.security.authenticate();
+ this.logger.debug(
+ `Successfully authenticated "${enrollmentResponse.body.token.name}" token to "${host}" host.`
+ );
+ } catch (err) {
+ this.logger.error(
+ `Failed to authenticate "${
+ enrollmentResponse.body.token.name
+ }" token to "${host}" host: ${getDetailedErrorMessage(err)}.`
+ );
+ throw err;
+ } finally {
+ await authenticateClient.close();
+ }
+
+ return enrollResult;
+ }
+
+ throw new Error('Unable to connect to any of the provided hosts.');
+ }
+
+ private static createPemCertificate(derCaString: string) {
+ // Use `X509Certificate` class once we upgrade to Node v16.
+ return `-----BEGIN CERTIFICATE-----\n${derCaString
+ .replace(/_/g, '/')
+ .replace(/-/g, '+')
+ .replace(/([^\n]{1,65})/g, '$1\n')
+ .replace(/\n$/g, '')}\n-----END CERTIFICATE-----\n`;
+ }
+}
diff --git a/src/plugins/interactive_setup/server/errors.test.ts b/src/plugins/interactive_setup/server/errors.test.ts
new file mode 100644
index 0000000000000..e9ef64fb0d3d7
--- /dev/null
+++ b/src/plugins/interactive_setup/server/errors.test.ts
@@ -0,0 +1,57 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { errors as esErrors } from '@elastic/elasticsearch';
+
+import * as errors from './errors';
+import { interactiveSetupMock } from './mocks';
+
+describe('errors', () => {
+ describe('#getErrorStatusCode', () => {
+ it('extracts status code from Elasticsearch client response error', () => {
+ expect(
+ errors.getErrorStatusCode(
+ new esErrors.ResponseError(
+ interactiveSetupMock.createApiResponse({ statusCode: 400, body: {} })
+ )
+ )
+ ).toBe(400);
+ expect(
+ errors.getErrorStatusCode(
+ new esErrors.ResponseError(
+ interactiveSetupMock.createApiResponse({ statusCode: 401, body: {} })
+ )
+ )
+ ).toBe(401);
+ });
+
+ it('extracts status code from `status` property', () => {
+ expect(errors.getErrorStatusCode({ statusText: 'Bad Request', status: 400 })).toBe(400);
+ expect(errors.getErrorStatusCode({ statusText: 'Unauthorized', status: 401 })).toBe(401);
+ });
+ });
+
+ describe('#getDetailedErrorMessage', () => {
+ it('extracts body from Elasticsearch client response error', () => {
+ expect(
+ errors.getDetailedErrorMessage(
+ new esErrors.ResponseError(
+ interactiveSetupMock.createApiResponse({
+ statusCode: 401,
+ body: { field1: 'value-1', field2: 'value-2' },
+ })
+ )
+ )
+ ).toBe(JSON.stringify({ field1: 'value-1', field2: 'value-2' }));
+ });
+
+ it('extracts `message` property', () => {
+ expect(errors.getDetailedErrorMessage(new Error('some-message'))).toBe('some-message');
+ });
+ });
+});
diff --git a/src/plugins/interactive_setup/server/errors.ts b/src/plugins/interactive_setup/server/errors.ts
new file mode 100644
index 0000000000000..5f1d2388b3938
--- /dev/null
+++ b/src/plugins/interactive_setup/server/errors.ts
@@ -0,0 +1,35 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { errors } from '@elastic/elasticsearch';
+
+/**
+ * Extracts error code from Boom and Elasticsearch "native" errors.
+ * @param error Error instance to extract status code from.
+ */
+export function getErrorStatusCode(error: any): number {
+ if (error instanceof errors.ResponseError) {
+ return error.statusCode;
+ }
+
+ return error.statusCode || error.status;
+}
+
+/**
+ * Extracts detailed error message from Boom and Elasticsearch "native" errors. It's supposed to be
+ * only logged on the server side and never returned to the client as it may contain sensitive
+ * information.
+ * @param error Error instance to extract message from.
+ */
+export function getDetailedErrorMessage(error: any): string {
+ if (error instanceof errors.ResponseError) {
+ return JSON.stringify(error.body);
+ }
+
+ return error.message;
+}
diff --git a/src/plugins/interactive_setup/server/kibana_config_writer.mock.ts b/src/plugins/interactive_setup/server/kibana_config_writer.mock.ts
new file mode 100644
index 0000000000000..d2c498e5fc077
--- /dev/null
+++ b/src/plugins/interactive_setup/server/kibana_config_writer.mock.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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import type { PublicMethodsOf } from '@kbn/utility-types';
+
+import type { KibanaConfigWriter } from './kibana_config_writer';
+
+export const kibanaConfigWriterMock = {
+ create: (): jest.Mocked> => ({
+ isConfigWritable: jest.fn().mockResolvedValue(true),
+ writeConfig: jest.fn().mockResolvedValue(undefined),
+ }),
+};
diff --git a/src/plugins/interactive_setup/server/kibana_config_writer.test.ts b/src/plugins/interactive_setup/server/kibana_config_writer.test.ts
new file mode 100644
index 0000000000000..7ae98157ba156
--- /dev/null
+++ b/src/plugins/interactive_setup/server/kibana_config_writer.test.ts
@@ -0,0 +1,140 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+jest.mock('fs/promises');
+import { constants } from 'fs';
+
+import { loggingSystemMock } from 'src/core/server/mocks';
+
+import { KibanaConfigWriter } from './kibana_config_writer';
+
+describe('KibanaConfigWriter', () => {
+ let mockFsAccess: jest.Mock;
+ let mockWriteFile: jest.Mock;
+ let mockAppendFile: jest.Mock;
+ let kibanaConfigWriter: KibanaConfigWriter;
+ beforeEach(() => {
+ jest.spyOn(Date, 'now').mockReturnValue(1234);
+
+ const fsMocks = jest.requireMock('fs/promises');
+ mockFsAccess = fsMocks.access;
+ mockWriteFile = fsMocks.writeFile;
+ mockAppendFile = fsMocks.appendFile;
+
+ kibanaConfigWriter = new KibanaConfigWriter(
+ '/some/path/kibana.yml',
+ loggingSystemMock.createLogger()
+ );
+ });
+
+ afterEach(() => jest.resetAllMocks());
+
+ describe('#isConfigWritable()', () => {
+ it('returns `false` if config directory is not writable even if kibana yml is writable', async () => {
+ mockFsAccess.mockImplementation((path, modifier) =>
+ path === '/some/path' && modifier === constants.W_OK ? Promise.reject() : Promise.resolve()
+ );
+
+ await expect(kibanaConfigWriter.isConfigWritable()).resolves.toBe(false);
+ });
+
+ it('returns `false` if kibana yml is NOT writable if even config directory is writable', async () => {
+ mockFsAccess.mockImplementation((path, modifier) =>
+ path === '/some/path/kibana.yml' && modifier === constants.W_OK
+ ? Promise.reject()
+ : Promise.resolve()
+ );
+
+ await expect(kibanaConfigWriter.isConfigWritable()).resolves.toBe(false);
+ });
+
+ it('returns `true` if both kibana yml and config directory are writable', async () => {
+ mockFsAccess.mockResolvedValue(undefined);
+
+ await expect(kibanaConfigWriter.isConfigWritable()).resolves.toBe(true);
+ });
+
+ it('returns `true` even if kibana yml does not exist when config directory is writable', async () => {
+ mockFsAccess.mockImplementation((path) =>
+ path === '/some/path/kibana.yml' ? Promise.reject() : Promise.resolve()
+ );
+
+ await expect(kibanaConfigWriter.isConfigWritable()).resolves.toBe(true);
+ });
+ });
+
+ describe('#writeConfig()', () => {
+ it('throws if cannot write CA file', async () => {
+ mockWriteFile.mockRejectedValue(new Error('Oh no!'));
+
+ await expect(
+ kibanaConfigWriter.writeConfig({
+ ca: 'ca-content',
+ host: '',
+ serviceAccountToken: { name: '', value: '' },
+ })
+ ).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`);
+
+ expect(mockWriteFile).toHaveBeenCalledTimes(1);
+ expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
+ expect(mockAppendFile).not.toHaveBeenCalled();
+ });
+
+ it('throws if cannot append config to yaml file', async () => {
+ mockAppendFile.mockRejectedValue(new Error('Oh no!'));
+
+ await expect(
+ kibanaConfigWriter.writeConfig({
+ ca: 'ca-content',
+ host: 'some-host',
+ serviceAccountToken: { name: 'some-token', value: 'some-value' },
+ })
+ ).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`);
+
+ expect(mockWriteFile).toHaveBeenCalledTimes(1);
+ expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
+ expect(mockAppendFile).toHaveBeenCalledTimes(1);
+ expect(mockAppendFile).toHaveBeenCalledWith(
+ '/some/path/kibana.yml',
+ `
+
+# This section was automatically generated during setup (service account token name is "some-token").
+elasticsearch.hosts: [some-host]
+elasticsearch.serviceAccountToken: some-value
+elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
+
+`
+ );
+ });
+
+ it('can successfully write CA certificate and elasticsearch config to the disk', async () => {
+ await expect(
+ kibanaConfigWriter.writeConfig({
+ ca: 'ca-content',
+ host: 'some-host',
+ serviceAccountToken: { name: 'some-token', value: 'some-value' },
+ })
+ ).resolves.toBeUndefined();
+
+ expect(mockWriteFile).toHaveBeenCalledTimes(1);
+ expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
+ expect(mockAppendFile).toHaveBeenCalledTimes(1);
+ expect(mockAppendFile).toHaveBeenCalledWith(
+ '/some/path/kibana.yml',
+ `
+
+# This section was automatically generated during setup (service account token name is "some-token").
+elasticsearch.hosts: [some-host]
+elasticsearch.serviceAccountToken: some-value
+elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
+
+`
+ );
+ });
+ });
+});
diff --git a/src/plugins/interactive_setup/server/kibana_config_writer.ts b/src/plugins/interactive_setup/server/kibana_config_writer.ts
new file mode 100644
index 0000000000000..b3178d9a909bd
--- /dev/null
+++ b/src/plugins/interactive_setup/server/kibana_config_writer.ts
@@ -0,0 +1,93 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { constants } from 'fs';
+import fs from 'fs/promises';
+import yaml from 'js-yaml';
+import path from 'path';
+
+import type { Logger } from 'src/core/server';
+
+import { getDetailedErrorMessage } from './errors';
+
+export interface WriteConfigParameters {
+ host: string;
+ ca: string;
+ serviceAccountToken: { name: string; value: string };
+}
+
+export class KibanaConfigWriter {
+ constructor(private readonly configPath: string, private readonly logger: Logger) {}
+
+ /**
+ * Checks if we can write to the Kibana configuration file and configuration directory.
+ */
+ public async isConfigWritable() {
+ try {
+ // We perform two separate checks here:
+ // 1. If we can write to config directory to add a new CA certificate file and potentially Kibana configuration
+ // file if it doesn't exist for some reason.
+ // 2. If we can write to the Kibana configuration file if it exists.
+ const canWriteToConfigDirectory = fs.access(path.dirname(this.configPath), constants.W_OK);
+ await Promise.all([
+ canWriteToConfigDirectory,
+ fs.access(this.configPath, constants.F_OK).then(
+ () => fs.access(this.configPath, constants.W_OK),
+ () => canWriteToConfigDirectory
+ ),
+ ]);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * Writes Elasticsearch configuration to the disk.
+ * @param params
+ */
+ public async writeConfig(params: WriteConfigParameters) {
+ const caPath = path.join(path.dirname(this.configPath), `ca_${Date.now()}.crt`);
+
+ this.logger.debug(`Writing CA certificate to ${caPath}.`);
+ try {
+ await fs.writeFile(caPath, params.ca);
+ this.logger.debug(`Successfully wrote CA certificate to ${caPath}.`);
+ } catch (err) {
+ this.logger.error(
+ `Failed to write CA certificate to ${caPath}: ${getDetailedErrorMessage(err)}.`
+ );
+ throw err;
+ }
+
+ this.logger.debug(`Writing Elasticsearch configuration to ${this.configPath}.`);
+ try {
+ await fs.appendFile(
+ this.configPath,
+ `\n\n# This section was automatically generated during setup (service account token name is "${
+ params.serviceAccountToken.name
+ }").\n${yaml.safeDump(
+ {
+ 'elasticsearch.hosts': [params.host],
+ 'elasticsearch.serviceAccountToken': params.serviceAccountToken.value,
+ 'elasticsearch.ssl.certificateAuthorities': [caPath],
+ },
+ { flowLevel: 1 }
+ )}\n`
+ );
+ this.logger.debug(`Successfully wrote Elasticsearch configuration to ${this.configPath}.`);
+ } catch (err) {
+ this.logger.error(
+ `Failed to write Elasticsearch configuration to ${
+ this.configPath
+ }: ${getDetailedErrorMessage(err)}.`
+ );
+ throw err;
+ }
+ }
+}
diff --git a/src/plugins/interactive_setup/server/mocks.ts b/src/plugins/interactive_setup/server/mocks.ts
new file mode 100644
index 0000000000000..75b28a502b6d4
--- /dev/null
+++ b/src/plugins/interactive_setup/server/mocks.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import type { ApiResponse } from '@elastic/elasticsearch';
+
+function createApiResponseMock(
+ apiResponse: Pick, 'body'> &
+ Partial, 'body'>>
+): ApiResponse {
+ return {
+ statusCode: null,
+ headers: null,
+ warnings: null,
+ meta: {} as any,
+ ...apiResponse,
+ };
+}
+
+export const interactiveSetupMock = {
+ createApiResponse: createApiResponseMock,
+};
diff --git a/src/plugins/interactive_setup/server/plugin.ts b/src/plugins/interactive_setup/server/plugin.ts
index 6b2a12bad76bc..06ece32ba9c4e 100644
--- a/src/plugins/interactive_setup/server/plugin.ts
+++ b/src/plugins/interactive_setup/server/plugin.ts
@@ -13,11 +13,18 @@ import type { CorePreboot, Logger, PluginInitializerContext, PrebootPlugin } fro
import { ElasticsearchConnectionStatus } from '../common';
import type { ConfigSchema, ConfigType } from './config';
+import { ElasticsearchService } from './elasticsearch_service';
+import { KibanaConfigWriter } from './kibana_config_writer';
import { defineRoutes } from './routes';
export class UserSetupPlugin implements PrebootPlugin {
readonly #logger: Logger;
+ #elasticsearchConnectionStatusSubscription?: Subscription;
+ readonly #elasticsearch = new ElasticsearchService(
+ this.initializerContext.logger.get('elasticsearch')
+ );
+
#configSubscription?: Subscription;
#config?: ConfigType;
readonly #getConfig = () => {
@@ -27,11 +34,6 @@ export class UserSetupPlugin implements PrebootPlugin {
return this.#config;
};
- #elasticsearchConnectionStatus = ElasticsearchConnectionStatus.Unknown;
- readonly #getElasticsearchConnectionStatus = () => {
- return this.#elasticsearchConnectionStatus;
- };
-
constructor(private readonly initializerContext: PluginInitializerContext) {
this.#logger = this.initializerContext.logger.get();
}
@@ -65,45 +67,48 @@ export class UserSetupPlugin implements PrebootPlugin {
})
);
- // If preliminary check above indicates that user didn't alter default Elasticsearch connection
- // details, it doesn't mean Elasticsearch connection isn't configured. There is a chance that they
- // already disabled security features in Elasticsearch and everything should work by default.
- // We should check if we can connect to Elasticsearch with default configuration to know if we
- // need to activate interactive setup. This check can take some time, so we should register our
- // routes to let interactive setup UI to handle user requests until the check is complete.
- core.elasticsearch
- .createClient('ping')
- .asInternalUser.ping()
- .then(
- (pingResponse) => {
- if (pingResponse.body) {
- this.#logger.debug(
- 'Kibana is already properly configured to connect to Elasticsearch. Interactive setup mode will not be activated.'
- );
- this.#elasticsearchConnectionStatus = ElasticsearchConnectionStatus.Configured;
- completeSetup({ shouldReloadConfig: false });
- } else {
- this.#logger.debug(
- 'Kibana is not properly configured to connect to Elasticsearch. Interactive setup mode will be activated.'
- );
- this.#elasticsearchConnectionStatus = ElasticsearchConnectionStatus.NotConfigured;
- }
- },
- () => {
- // TODO: we should probably react differently to different errors. 401 - credentials aren't correct, etc.
- // Do we want to constantly ping ES if interactive mode UI isn't active? Just in case user runs Kibana and then
- // configure Elasticsearch so that it can eventually connect to it without any configuration changes?
- this.#elasticsearchConnectionStatus = ElasticsearchConnectionStatus.NotConfigured;
+ // If preliminary checks above indicate that user didn't alter default Elasticsearch connection
+ // details, it doesn't mean Elasticsearch connection isn't configured. There is a chance that
+ // user has already disabled security features in Elasticsearch and everything should work by
+ // default. We should check if we can connect to Elasticsearch with default configuration to
+ // know if we need to activate interactive setup. This check can take some time, so we should
+ // register our routes to let interactive setup UI to handle user requests until the check is
+ // complete. Moreover Elasticsearch may be just temporarily unavailable and we should poll its
+ // status until we can connect or use configures connection via interactive setup mode.
+ const elasticsearch = this.#elasticsearch.setup({
+ elasticsearch: core.elasticsearch,
+ connectionCheckInterval: this.#getConfig().connectionCheck.interval,
+ });
+ this.#elasticsearchConnectionStatusSubscription = elasticsearch.connectionStatus$.subscribe(
+ (status) => {
+ if (status === ElasticsearchConnectionStatus.Configured) {
+ this.#logger.debug(
+ 'Skipping interactive setup mode since Kibana is already properly configured to connect to Elasticsearch at http://localhost:9200.'
+ );
+ completeSetup({ shouldReloadConfig: false });
+ } else {
+ this.#logger.debug(
+ 'Starting interactive setup mode since Kibana cannot to connect to Elasticsearch at http://localhost:9200.'
+ );
}
- );
+ }
+ );
+
+ // If possible, try to use `*.dev.yml` config when Kibana is run in development mode.
+ const configPath = this.initializerContext.env.mode.dev
+ ? this.initializerContext.env.configs.find((config) => config.endsWith('.dev.yml')) ??
+ this.initializerContext.env.configs[0]
+ : this.initializerContext.env.configs[0];
core.http.registerRoutes('', (router) => {
defineRoutes({
router,
basePath: core.http.basePath,
logger: this.#logger.get('routes'),
+ preboot: { ...core.preboot, completeSetup },
+ kibanaConfigWriter: new KibanaConfigWriter(configPath, this.#logger.get('kibana-config')),
+ elasticsearch,
getConfig: this.#getConfig.bind(this),
- getElasticsearchConnectionStatus: this.#getElasticsearchConnectionStatus.bind(this),
});
});
}
@@ -115,5 +120,12 @@ export class UserSetupPlugin implements PrebootPlugin {
this.#configSubscription.unsubscribe();
this.#configSubscription = undefined;
}
+
+ if (this.#elasticsearchConnectionStatusSubscription) {
+ this.#elasticsearchConnectionStatusSubscription.unsubscribe();
+ this.#elasticsearchConnectionStatusSubscription = undefined;
+ }
+
+ this.#elasticsearch.stop();
}
}
diff --git a/src/plugins/interactive_setup/server/routes/enroll.test.ts b/src/plugins/interactive_setup/server/routes/enroll.test.ts
new file mode 100644
index 0000000000000..4fc91e5252480
--- /dev/null
+++ b/src/plugins/interactive_setup/server/routes/enroll.test.ts
@@ -0,0 +1,305 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { errors } from '@elastic/elasticsearch';
+
+import type { ObjectType } from '@kbn/config-schema';
+import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from 'src/core/server';
+import { kibanaResponseFactory } from 'src/core/server';
+import { httpServerMock } from 'src/core/server/mocks';
+
+import { ElasticsearchConnectionStatus } from '../../common';
+import { interactiveSetupMock } from '../mocks';
+import { defineEnrollRoutes } from './enroll';
+import { routeDefinitionParamsMock } from './index.mock';
+
+describe('Enroll routes', () => {
+ let router: jest.Mocked;
+ let mockRouteParams: ReturnType;
+ let mockContext: RequestHandlerContext;
+ beforeEach(() => {
+ mockRouteParams = routeDefinitionParamsMock.create();
+ router = mockRouteParams.router;
+
+ mockContext = ({} as unknown) as RequestHandlerContext;
+
+ defineEnrollRoutes(mockRouteParams);
+ });
+
+ describe('#enroll', () => {
+ let routeHandler: RequestHandler;
+ let routeConfig: RouteConfig;
+
+ beforeEach(() => {
+ const [enrollRouteConfig, enrollRouteHandler] = router.post.mock.calls.find(
+ ([{ path }]) => path === '/internal/interactive_setup/enroll'
+ )!;
+
+ routeConfig = enrollRouteConfig;
+ routeHandler = enrollRouteHandler;
+ });
+
+ it('correctly defines route.', () => {
+ expect(routeConfig.options).toEqual({ authRequired: false });
+
+ const bodySchema = (routeConfig.validate as any).body as ObjectType;
+ expect(() => bodySchema.validate({})).toThrowErrorMatchingInlineSnapshot(
+ `"[hosts]: expected value of type [array] but got [undefined]"`
+ );
+
+ expect(() => bodySchema.validate({ hosts: [] })).toThrowErrorMatchingInlineSnapshot(
+ `"[hosts]: array size is [0], but cannot be smaller than [1]"`
+ );
+ expect(() =>
+ bodySchema.validate({ hosts: ['localhost:9200'] })
+ ).toThrowErrorMatchingInlineSnapshot(`"[hosts.0]: expected URI with scheme [https]."`);
+ expect(() =>
+ bodySchema.validate({ hosts: ['http://localhost:9200'] })
+ ).toThrowErrorMatchingInlineSnapshot(`"[hosts.0]: expected URI with scheme [https]."`);
+ expect(() =>
+ bodySchema.validate({
+ apiKey: 'some-key',
+ hosts: ['https://localhost:9200', 'http://localhost:9243'],
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`"[hosts.1]: expected URI with scheme [https]."`);
+
+ expect(() =>
+ bodySchema.validate({ hosts: ['https://localhost:9200'] })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[apiKey]: expected value of type [string] but got [undefined]"`
+ );
+ expect(() =>
+ bodySchema.validate({ apiKey: '', hosts: ['https://localhost:9200'] })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[apiKey]: value has length [0] but it must have a minimum length of [1]."`
+ );
+
+ expect(() =>
+ bodySchema.validate({ apiKey: 'some-key', hosts: ['https://localhost:9200'] })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[caFingerprint]: expected value of type [string] but got [undefined]"`
+ );
+ expect(() =>
+ bodySchema.validate({
+ apiKey: 'some-key',
+ hosts: ['https://localhost:9200'],
+ caFingerprint: '12345',
+ })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[caFingerprint]: value has length [5] but it must have a minimum length of [64]."`
+ );
+
+ expect(
+ bodySchema.validate(
+ bodySchema.validate({
+ apiKey: 'some-key',
+ hosts: ['https://localhost:9200'],
+ caFingerprint: 'a'.repeat(64),
+ })
+ )
+ ).toEqual({
+ apiKey: 'some-key',
+ hosts: ['https://localhost:9200'],
+ caFingerprint: 'a'.repeat(64),
+ });
+ });
+
+ it('fails if setup is not on hold.', async () => {
+ mockRouteParams.preboot.isSetupOnHold.mockReturnValue(false);
+
+ const mockRequest = httpServerMock.createKibanaRequest({
+ body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' },
+ });
+
+ await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
+ status: 400,
+ options: { body: 'Cannot process request outside of preboot stage.' },
+ payload: 'Cannot process request outside of preboot stage.',
+ });
+
+ expect(mockRouteParams.elasticsearch.enroll).not.toHaveBeenCalled();
+ expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
+ expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
+ });
+
+ it('fails if Elasticsearch connection is already configured.', async () => {
+ mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
+ mockRouteParams.elasticsearch.connectionStatus$.next(
+ ElasticsearchConnectionStatus.Configured
+ );
+
+ const mockRequest = httpServerMock.createKibanaRequest({
+ body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' },
+ });
+
+ await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
+ status: 400,
+ options: {
+ body: {
+ message: 'Elasticsearch connection is already configured.',
+ attributes: { type: 'elasticsearch_connection_configured' },
+ },
+ },
+ payload: {
+ message: 'Elasticsearch connection is already configured.',
+ attributes: { type: 'elasticsearch_connection_configured' },
+ },
+ });
+
+ expect(mockRouteParams.elasticsearch.enroll).not.toHaveBeenCalled();
+ expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
+ expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
+ });
+
+ it('fails if Kibana config is not writable.', async () => {
+ mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
+ mockRouteParams.elasticsearch.connectionStatus$.next(
+ ElasticsearchConnectionStatus.NotConfigured
+ );
+ mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(false);
+
+ const mockRequest = httpServerMock.createKibanaRequest({
+ body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' },
+ });
+
+ await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
+ status: 500,
+ options: {
+ body: {
+ message: 'Kibana process does not have enough permissions to write to config file.',
+ attributes: { type: 'kibana_config_not_writable' },
+ },
+ statusCode: 500,
+ },
+ payload: {
+ message: 'Kibana process does not have enough permissions to write to config file.',
+ attributes: { type: 'kibana_config_not_writable' },
+ },
+ });
+
+ expect(mockRouteParams.elasticsearch.enroll).not.toHaveBeenCalled();
+ expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
+ expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
+ });
+
+ it('fails if enroll call fails.', async () => {
+ mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
+ mockRouteParams.elasticsearch.connectionStatus$.next(
+ ElasticsearchConnectionStatus.NotConfigured
+ );
+ mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(true);
+ mockRouteParams.elasticsearch.enroll.mockRejectedValue(
+ new errors.ResponseError(
+ interactiveSetupMock.createApiResponse({
+ statusCode: 401,
+ body: { message: 'some-secret-message' },
+ })
+ )
+ );
+
+ const mockRequest = httpServerMock.createKibanaRequest({
+ body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' },
+ });
+
+ await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
+ status: 500,
+ options: {
+ body: { message: 'Failed to enroll.', attributes: { type: 'enroll_failure' } },
+ statusCode: 500,
+ },
+ payload: { message: 'Failed to enroll.', attributes: { type: 'enroll_failure' } },
+ });
+
+ expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledTimes(1);
+ expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
+ expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
+ });
+
+ it('fails if cannot write configuration to the disk.', async () => {
+ mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
+ mockRouteParams.elasticsearch.connectionStatus$.next(
+ ElasticsearchConnectionStatus.NotConfigured
+ );
+ mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(true);
+ mockRouteParams.elasticsearch.enroll.mockResolvedValue({
+ ca: 'some-ca',
+ host: 'host',
+ serviceAccountToken: { name: 'some-name', value: 'some-value' },
+ });
+ mockRouteParams.kibanaConfigWriter.writeConfig.mockRejectedValue(
+ new Error('Some error with sensitive path')
+ );
+
+ const mockRequest = httpServerMock.createKibanaRequest({
+ body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' },
+ });
+
+ await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
+ status: 500,
+ options: {
+ body: {
+ message: 'Failed to save configuration.',
+ attributes: { type: 'kibana_config_failure' },
+ },
+ statusCode: 500,
+ },
+ payload: {
+ message: 'Failed to save configuration.',
+ attributes: { type: 'kibana_config_failure' },
+ },
+ });
+
+ expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledTimes(1);
+ expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1);
+ expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
+ });
+
+ it('can successfully enrol and save configuration to the disk.', async () => {
+ mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
+ mockRouteParams.elasticsearch.connectionStatus$.next(
+ ElasticsearchConnectionStatus.NotConfigured
+ );
+ mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(true);
+ mockRouteParams.elasticsearch.enroll.mockResolvedValue({
+ ca: 'some-ca',
+ host: 'host',
+ serviceAccountToken: { name: 'some-name', value: 'some-value' },
+ });
+ mockRouteParams.kibanaConfigWriter.writeConfig.mockResolvedValue();
+
+ const mockRequest = httpServerMock.createKibanaRequest({
+ body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' },
+ });
+
+ await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
+ status: 204,
+ options: {},
+ payload: undefined,
+ });
+
+ expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledTimes(1);
+ expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledWith({
+ apiKey: 'some-key',
+ hosts: ['host1', 'host2'],
+ caFingerprint: 'ab:cd:ef',
+ });
+
+ expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1);
+ expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledWith({
+ ca: 'some-ca',
+ host: 'host',
+ serviceAccountToken: { name: 'some-name', value: 'some-value' },
+ });
+
+ expect(mockRouteParams.preboot.completeSetup).toHaveBeenCalledTimes(1);
+ expect(mockRouteParams.preboot.completeSetup).toHaveBeenCalledWith({
+ shouldReloadConfig: true,
+ });
+ });
+ });
+});
diff --git a/src/plugins/interactive_setup/server/routes/enroll.ts b/src/plugins/interactive_setup/server/routes/enroll.ts
index a600d18109760..91b391bf8b109 100644
--- a/src/plugins/interactive_setup/server/routes/enroll.ts
+++ b/src/plugins/interactive_setup/server/routes/enroll.ts
@@ -6,26 +6,105 @@
* Side Public License, v 1.
*/
+import { first } from 'rxjs/operators';
+
import { schema } from '@kbn/config-schema';
+import { ElasticsearchConnectionStatus } from '../../common';
+import type { EnrollResult } from '../elasticsearch_service';
import type { RouteDefinitionParams } from './';
/**
* Defines routes to deal with Elasticsearch `enroll_kibana` APIs.
*/
-export function defineEnrollRoutes({ router }: RouteDefinitionParams) {
+export function defineEnrollRoutes({
+ router,
+ logger,
+ kibanaConfigWriter,
+ elasticsearch,
+ preboot,
+}: RouteDefinitionParams) {
router.post(
{
path: '/internal/interactive_setup/enroll',
validate: {
- body: schema.object({ token: schema.string() }),
+ body: schema.object({
+ hosts: schema.arrayOf(schema.uri({ scheme: 'https' }), {
+ minSize: 1,
+ }),
+ apiKey: schema.string({ minLength: 1 }),
+ caFingerprint: schema.string({ maxLength: 64, minLength: 64 }),
+ }),
},
options: { authRequired: false },
},
async (context, request, response) => {
- return response.forbidden({
- body: { message: `API is not implemented yet.` },
- });
+ if (!preboot.isSetupOnHold()) {
+ logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`);
+ return response.badRequest({ body: 'Cannot process request outside of preboot stage.' });
+ }
+
+ const connectionStatus = await elasticsearch.connectionStatus$.pipe(first()).toPromise();
+ if (connectionStatus === ElasticsearchConnectionStatus.Configured) {
+ logger.error(
+ `Invalid request to [path=${request.url.pathname}], Elasticsearch connection is already configured.`
+ );
+ return response.badRequest({
+ body: {
+ message: 'Elasticsearch connection is already configured.',
+ attributes: { type: 'elasticsearch_connection_configured' },
+ },
+ });
+ }
+
+ // The most probable misconfiguration case is when Kibana process isn't allowed to write to the
+ // Kibana configuration file. We'll still have to handle possible filesystem access errors
+ // when we actually write to the disk, but this preliminary check helps us to avoid unnecessary
+ // enrollment call and communicate that to the user early.
+ const isConfigWritable = await kibanaConfigWriter.isConfigWritable();
+ if (!isConfigWritable) {
+ logger.error('Kibana process does not have enough permissions to write to config file');
+ return response.customError({
+ statusCode: 500,
+ body: {
+ message: 'Kibana process does not have enough permissions to write to config file.',
+ attributes: { type: 'kibana_config_not_writable' },
+ },
+ });
+ }
+
+ let enrollResult: EnrollResult;
+ try {
+ enrollResult = await elasticsearch.enroll({
+ apiKey: request.body.apiKey,
+ hosts: request.body.hosts,
+ caFingerprint: request.body.caFingerprint,
+ });
+ } catch {
+ // For security reasons, we shouldn't leak to the user whether Elasticsearch node couldn't process enrollment
+ // request or we just couldn't connect to any of the provided hosts.
+ return response.customError({
+ statusCode: 500,
+ body: { message: 'Failed to enroll.', attributes: { type: 'enroll_failure' } },
+ });
+ }
+
+ try {
+ await kibanaConfigWriter.writeConfig(enrollResult);
+ } catch {
+ // For security reasons, we shouldn't leak any filesystem related errors.
+ return response.customError({
+ statusCode: 500,
+ body: {
+ message: 'Failed to save configuration.',
+ attributes: { type: 'kibana_config_failure' },
+ },
+ });
+ }
+
+ preboot.completeSetup({ shouldReloadConfig: true });
+
+ return response.noContent();
}
);
}
diff --git a/src/plugins/interactive_setup/server/routes/index.mock.ts b/src/plugins/interactive_setup/server/routes/index.mock.ts
new file mode 100644
index 0000000000000..249d1277269e7
--- /dev/null
+++ b/src/plugins/interactive_setup/server/routes/index.mock.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { coreMock, httpServiceMock, loggingSystemMock } from 'src/core/server/mocks';
+
+import { ConfigSchema } from '../config';
+import { elasticsearchServiceMock } from '../elasticsearch_service.mock';
+import { kibanaConfigWriterMock } from '../kibana_config_writer.mock';
+
+export const routeDefinitionParamsMock = {
+ create: (config: Record = {}) => ({
+ router: httpServiceMock.createRouter(),
+ basePath: httpServiceMock.createBasePath(),
+ csp: httpServiceMock.createSetupContract().csp,
+ logger: loggingSystemMock.create().get(),
+ preboot: { ...coreMock.createPreboot().preboot, completeSetup: jest.fn() },
+ getConfig: jest.fn().mockReturnValue(ConfigSchema.validate(config)),
+ elasticsearch: elasticsearchServiceMock.createSetup(),
+ kibanaConfigWriter: kibanaConfigWriterMock.create(),
+ }),
+};
diff --git a/src/plugins/interactive_setup/server/routes/index.ts b/src/plugins/interactive_setup/server/routes/index.ts
index 0f14f5ffac8ec..752c5828ecb59 100644
--- a/src/plugins/interactive_setup/server/routes/index.ts
+++ b/src/plugins/interactive_setup/server/routes/index.ts
@@ -6,10 +6,12 @@
* Side Public License, v 1.
*/
-import type { IBasePath, IRouter, Logger } from 'src/core/server';
+import type { PublicMethodsOf } from '@kbn/utility-types';
+import type { IBasePath, IRouter, Logger, PrebootServicePreboot } from 'src/core/server';
-import type { ElasticsearchConnectionStatus } from '../../common';
import type { ConfigType } from '../config';
+import type { ElasticsearchServiceSetup } from '../elasticsearch_service';
+import type { KibanaConfigWriter } from '../kibana_config_writer';
import { defineEnrollRoutes } from './enroll';
/**
@@ -19,8 +21,12 @@ export interface RouteDefinitionParams {
readonly router: IRouter;
readonly basePath: IBasePath;
readonly logger: Logger;
+ readonly preboot: PrebootServicePreboot & {
+ completeSetup: (result: { shouldReloadConfig: boolean }) => void;
+ };
+ readonly kibanaConfigWriter: PublicMethodsOf;
+ readonly elasticsearch: ElasticsearchServiceSetup;
readonly getConfig: () => ConfigType;
- readonly getElasticsearchConnectionStatus: () => ElasticsearchConnectionStatus;
}
export function defineRoutes(params: RouteDefinitionParams) {
diff --git a/src/plugins/vis_default_editor/public/components/options/index.ts b/src/plugins/vis_default_editor/public/components/options/index.ts
index 31b09977f5c99..62ce76014f9fc 100644
--- a/src/plugins/vis_default_editor/public/components/options/index.ts
+++ b/src/plugins/vis_default_editor/public/components/options/index.ts
@@ -16,3 +16,4 @@ export { RangeOption } from './range';
export { RequiredNumberInputOption } from './required_number_input';
export { TextInputOption } from './text_input';
export { PercentageModeOption } from './percentage_mode';
+export { LongLegendOptions } from './long_legend_options';
diff --git a/src/plugins/vis_default_editor/public/components/options/long_legend_options.test.tsx b/src/plugins/vis_default_editor/public/components/options/long_legend_options.test.tsx
new file mode 100644
index 0000000000000..69994bb279278
--- /dev/null
+++ b/src/plugins/vis_default_editor/public/components/options/long_legend_options.test.tsx
@@ -0,0 +1,48 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React from 'react';
+import { mountWithIntl } from '@kbn/test/jest';
+import { LongLegendOptions, LongLegendOptionsProps } from './long_legend_options';
+import { EuiFieldNumber } from '@elastic/eui';
+
+describe('LongLegendOptions', () => {
+ let props: LongLegendOptionsProps;
+ let component;
+ beforeAll(() => {
+ props = {
+ truncateLegend: true,
+ setValue: jest.fn(),
+ };
+ });
+
+ it('renders the EuiFieldNumber', () => {
+ component = mountWithIntl();
+ expect(component.find(EuiFieldNumber).length).toBe(1);
+ });
+
+ it('should call setValue when value is changes in the number input', () => {
+ component = mountWithIntl();
+ const numberField = component.find(EuiFieldNumber);
+ numberField.props().onChange!(({
+ target: {
+ value: 3,
+ },
+ } as unknown) as React.ChangeEvent);
+
+ expect(props.setValue).toHaveBeenCalledWith('maxLegendLines', 3);
+ });
+
+ it('input number should be disabled when truncate is false', () => {
+ props.truncateLegend = false;
+ component = mountWithIntl();
+ const numberField = component.find(EuiFieldNumber);
+
+ expect(numberField.props().disabled).toBeTruthy();
+ });
+});
diff --git a/src/plugins/vis_default_editor/public/components/options/long_legend_options.tsx b/src/plugins/vis_default_editor/public/components/options/long_legend_options.tsx
new file mode 100644
index 0000000000000..c06fb94376dbe
--- /dev/null
+++ b/src/plugins/vis_default_editor/public/components/options/long_legend_options.tsx
@@ -0,0 +1,71 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React from 'react';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { EuiFieldNumber, EuiFormRow } from '@elastic/eui';
+import { SwitchOption } from './switch';
+
+const MAX_TRUNCATE_LINES = 5;
+const MIN_TRUNCATE_LINES = 1;
+
+export interface LongLegendOptionsProps {
+ setValue: (paramName: 'maxLegendLines' | 'truncateLegend', value: boolean | number) => void;
+ truncateLegend: boolean;
+ maxLegendLines?: number;
+ 'data-test-subj'?: string;
+}
+
+function LongLegendOptions({
+ 'data-test-subj': dataTestSubj,
+ setValue,
+ truncateLegend,
+ maxLegendLines,
+}: LongLegendOptionsProps) {
+ return (
+ <>
+
+
+ }
+ >
+ {
+ const val = Number(e.target.value);
+ setValue(
+ 'maxLegendLines',
+ Math.min(MAX_TRUNCATE_LINES, Math.max(val, MIN_TRUNCATE_LINES))
+ );
+ }}
+ />
+
+ >
+ );
+}
+
+export { LongLegendOptions };
diff --git a/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap b/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap
index 6c43072b97c28..fb51717d1adc0 100644
--- a/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap
+++ b/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap
@@ -57,6 +57,7 @@ Object {
"valuesFormat": "percent",
},
"legendPosition": "right",
+ "maxLegendLines": true,
"metric": Object {
"accessor": 0,
"aggType": "count",
@@ -72,6 +73,7 @@ Object {
},
"splitColumn": undefined,
"splitRow": undefined,
+ "truncateLegend": true,
},
"visData": Object {
"columns": Array [
diff --git a/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx b/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx
index 524986524fd7e..d37f4c10ea9ea 100644
--- a/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx
+++ b/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx
@@ -73,6 +73,20 @@ describe('PalettePicker', function () {
});
});
+ it('renders the long legend options for the elastic charts implementation', async () => {
+ component = mountWithIntl();
+ await act(async () => {
+ expect(findTestSubject(component, 'pieLongLegendsOptions').length).toBe(1);
+ });
+ });
+
+ it('not renders the long legend options for the vislib implementation', async () => {
+ component = mountWithIntl();
+ await act(async () => {
+ expect(findTestSubject(component, 'pieLongLegendsOptions').length).toBe(0);
+ });
+ });
+
it('renders the label position dropdown for the elastic charts implementation', async () => {
component = mountWithIntl();
await act(async () => {
diff --git a/src/plugins/vis_type_pie/public/editor/components/pie.tsx b/src/plugins/vis_type_pie/public/editor/components/pie.tsx
index 8ce4f4defbaed..3bf28ba58d4eb 100644
--- a/src/plugins/vis_type_pie/public/editor/components/pie.tsx
+++ b/src/plugins/vis_type_pie/public/editor/components/pie.tsx
@@ -26,6 +26,7 @@ import {
SwitchOption,
SelectOption,
PalettePicker,
+ LongLegendOptions,
} from '../../../../vis_default_editor/public';
import { VisEditorOptionsProps } from '../../../../visualizations/public';
import { TruncateLabelsOption } from './truncate_labels';
@@ -169,6 +170,12 @@ const PieOptions = (props: PieOptionsProps) => {
}}
data-test-subj="visTypePieNestedLegendSwitch"
/>
+
>
)}
{props.showElasticChartsOptions && palettesRegistry && (
@@ -276,7 +283,13 @@ const PieOptions = (props: PieOptionsProps) => {
/>
>
)}
-
+
>
);
diff --git a/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx
index e6eb56725753c..d4c798498e8b0 100644
--- a/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx
+++ b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx
@@ -8,7 +8,7 @@
import React, { ChangeEvent } from 'react';
import { i18n } from '@kbn/i18n';
-import { EuiFormRow, EuiFieldNumber } from '@elastic/eui';
+import { EuiFormRow, EuiFieldNumber, EuiIconTip } from '@elastic/eui';
export interface TruncateLabelsOptionProps {
disabled?: boolean;
@@ -27,6 +27,16 @@ function TruncateLabelsOption({ disabled, value = null, setValue }: TruncateLabe
})}
fullWidth
display="rowCompressed"
+ labelAppend={
+
+ }
>
{
},
legendPosition: 'right',
nestedLegend: false,
+ maxLegendLines: 1,
+ truncateLegend: true,
distinctColors: false,
palette: {
name: 'default',
diff --git a/src/plugins/vis_type_pie/public/pie_component.tsx b/src/plugins/vis_type_pie/public/pie_component.tsx
index c0f4a8a6112f8..9119f2f2ecd6c 100644
--- a/src/plugins/vis_type_pie/public/pie_component.tsx
+++ b/src/plugins/vis_type_pie/public/pie_component.tsx
@@ -320,7 +320,16 @@ const PieComponent = (props: PieComponentProps) => {
services.actions,
services.fieldFormats
)}
- theme={chartTheme}
+ theme={[
+ chartTheme,
+ {
+ legend: {
+ labelOptions: {
+ maxLines: visParams.truncateLegend ? visParams.maxLegendLines ?? 1 : 0,
+ },
+ },
+ },
+ ]}
baseTheme={chartBaseTheme}
onRenderChange={onRenderChange}
/>
diff --git a/src/plugins/vis_type_pie/public/pie_fn.test.ts b/src/plugins/vis_type_pie/public/pie_fn.test.ts
index 3dcef406379c2..33b5f38cbe630 100644
--- a/src/plugins/vis_type_pie/public/pie_fn.test.ts
+++ b/src/plugins/vis_type_pie/public/pie_fn.test.ts
@@ -23,6 +23,8 @@ describe('interpreter/functions#pie', () => {
legendPosition: 'right',
isDonut: true,
nestedLegend: true,
+ truncateLegend: true,
+ maxLegendLines: true,
distinctColors: false,
palette: 'kibana_palette',
labels: {
diff --git a/src/plugins/vis_type_pie/public/pie_fn.ts b/src/plugins/vis_type_pie/public/pie_fn.ts
index 65ac648ca2868..c5987001d4494 100644
--- a/src/plugins/vis_type_pie/public/pie_fn.ts
+++ b/src/plugins/vis_type_pie/public/pie_fn.ts
@@ -89,6 +89,19 @@ export const createPieVisFn = (): VisTypePieExpressionFunctionDefinition => ({
}),
default: false,
},
+ truncateLegend: {
+ types: ['boolean'],
+ help: i18n.translate('visTypePie.function.args.truncateLegendHelpText', {
+ defaultMessage: 'Defines if the legend items will be truncated or not',
+ }),
+ default: true,
+ },
+ maxLegendLines: {
+ types: ['number'],
+ help: i18n.translate('visTypePie.function.args.maxLegendLinesHelpText', {
+ defaultMessage: 'Defines the number of lines per legend item',
+ }),
+ },
distinctColors: {
types: ['boolean'],
help: i18n.translate('visTypePie.function.args.distinctColorsHelpText', {
diff --git a/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts b/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts
index 41fa00bbe2386..26d9c526a8137 100644
--- a/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts
+++ b/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts
@@ -28,6 +28,8 @@ export const samplePieVis = {
legendPosition: 'right',
isDonut: true,
nestedLegend: true,
+ truncateLegend: true,
+ maxLegendLines: 1,
distinctColors: false,
palette: 'kibana_palette',
labels: {
diff --git a/src/plugins/vis_type_pie/public/to_ast.ts b/src/plugins/vis_type_pie/public/to_ast.ts
index e8c9f301b4366..b360e375bf40d 100644
--- a/src/plugins/vis_type_pie/public/to_ast.ts
+++ b/src/plugins/vis_type_pie/public/to_ast.ts
@@ -50,6 +50,8 @@ export const toExpressionAst: VisToExpressionAst = async (vis, par
addLegend: vis.params.addLegend,
legendPosition: vis.params.legendPosition,
nestedLegend: vis.params?.nestedLegend,
+ truncateLegend: vis.params.truncateLegend,
+ maxLegendLines: vis.params.maxLegendLines,
distinctColors: vis.params?.distinctColors,
isDonut: vis.params.isDonut,
palette: vis.params?.palette?.name,
diff --git a/src/plugins/vis_type_pie/public/types/types.ts b/src/plugins/vis_type_pie/public/types/types.ts
index 4f3365545d062..94eaeb55f7242 100644
--- a/src/plugins/vis_type_pie/public/types/types.ts
+++ b/src/plugins/vis_type_pie/public/types/types.ts
@@ -33,6 +33,8 @@ interface PieCommonParams {
addLegend: boolean;
legendPosition: Position;
nestedLegend: boolean;
+ truncateLegend: boolean;
+ maxLegendLines: number;
distinctColors: boolean;
isDonut: boolean;
}
diff --git a/src/plugins/vis_type_pie/public/utils/get_columns.test.ts b/src/plugins/vis_type_pie/public/utils/get_columns.test.ts
index 3170628ec2e12..9f64266ed0e0e 100644
--- a/src/plugins/vis_type_pie/public/utils/get_columns.test.ts
+++ b/src/plugins/vis_type_pie/public/utils/get_columns.test.ts
@@ -144,6 +144,8 @@ describe('getColumns', () => {
},
legendPosition: 'right',
nestedLegend: false,
+ maxLegendLines: 1,
+ truncateLegend: false,
palette: {
name: 'default',
type: 'palette',
diff --git a/src/plugins/vis_type_pie/public/utils/get_config.ts b/src/plugins/vis_type_pie/public/utils/get_config.ts
index a8a4edb01cd9c..40f8f84b127f9 100644
--- a/src/plugins/vis_type_pie/public/utils/get_config.ts
+++ b/src/plugins/vis_type_pie/public/utils/get_config.ts
@@ -63,6 +63,7 @@ export const getConfig = (
config.linkLabel = {
maxCount: Number.POSITIVE_INFINITY,
maximumSection: Number.POSITIVE_INFINITY,
+ maxTextLength: visParams.labels.truncate ?? undefined,
};
}
diff --git a/src/plugins/vis_type_pie/public/utils/get_layers.ts b/src/plugins/vis_type_pie/public/utils/get_layers.ts
index b995df83c0bb0..42c4650419c6b 100644
--- a/src/plugins/vis_type_pie/public/utils/get_layers.ts
+++ b/src/plugins/vis_type_pie/public/utils/get_layers.ts
@@ -151,12 +151,7 @@ export const getLayers = (
showAccessor: (d: Datum) => d !== EMPTY_SLICE,
nodeLabel: (d: unknown) => {
if (col.format) {
- const formattedLabel = formatter.deserialize(col.format).convert(d) ?? '';
- if (visParams.labels.truncate && formattedLabel.length <= visParams.labels.truncate) {
- return formattedLabel;
- } else {
- return `${formattedLabel.slice(0, Number(visParams.labels.truncate))}\u2026`;
- }
+ return formatter.deserialize(col.format).convert(d) ?? '';
}
return String(d);
},
diff --git a/src/plugins/vis_type_pie/public/vis_type/pie.ts b/src/plugins/vis_type_pie/public/vis_type/pie.ts
index 9d1556ac33ad7..95a9d0d41481b 100644
--- a/src/plugins/vis_type_pie/public/vis_type/pie.ts
+++ b/src/plugins/vis_type_pie/public/vis_type/pie.ts
@@ -35,6 +35,8 @@ export const getPieVisTypeDefinition = ({
addLegend: !showElasticChartsOptions,
legendPosition: Position.Right,
nestedLegend: false,
+ truncateLegend: true,
+ maxLegendLines: 1,
distinctColors: false,
isDonut: true,
palette: {
diff --git a/src/plugins/vis_type_timeseries/common/types/panel_model.ts b/src/plugins/vis_type_timeseries/common/types/panel_model.ts
index 2ac9125534ac7..ff942a30abbdc 100644
--- a/src/plugins/vis_type_timeseries/common/types/panel_model.ts
+++ b/src/plugins/vis_type_timeseries/common/types/panel_model.ts
@@ -161,6 +161,8 @@ export interface Panel {
series: Series[];
show_grid: number;
show_legend: number;
+ truncate_legend?: number;
+ max_lines_legend?: number;
time_field?: string;
time_range_mode?: string;
tooltip_mode?: TOOLTIP_MODES;
diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.test.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.test.tsx
new file mode 100644
index 0000000000000..02f28f3135880
--- /dev/null
+++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.test.tsx
@@ -0,0 +1,82 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React from 'react';
+import { shallowWithIntl as shallow } from '@kbn/test/jest';
+
+jest.mock('../lib/get_default_query_language', () => ({
+ getDefaultQueryLanguage: () => 'kuery',
+}));
+
+import { TimeseriesPanelConfig } from './timeseries';
+import { PanelConfigProps } from './types';
+
+describe('TimeseriesPanelConfig', () => {
+ it('sets the number input to the given value', () => {
+ const props = ({
+ fields: {},
+ model: {
+ max_lines_legend: 2,
+ },
+ onChange: jest.fn(),
+ } as unknown) as PanelConfigProps;
+ const wrapper = shallow();
+ wrapper.instance().setState({ selectedTab: 'options' });
+ expect(
+ wrapper.find('[data-test-subj="timeSeriesEditorDataMaxLegendLines"]').prop('value')
+ ).toEqual(2);
+ });
+
+ it('switches on the truncate legend switch if the prop is set to 1 ', () => {
+ const props = ({
+ fields: {},
+ model: {
+ max_lines_legend: 2,
+ truncate_legend: 1,
+ },
+ onChange: jest.fn(),
+ } as unknown) as PanelConfigProps;
+ const wrapper = shallow();
+ wrapper.instance().setState({ selectedTab: 'options' });
+ expect(
+ wrapper.find('[data-test-subj="timeSeriesEditorDataTruncateLegendSwitch"]').prop('value')
+ ).toEqual(1);
+ });
+
+ it('switches off the truncate legend switch if the prop is set to 0', () => {
+ const props = ({
+ fields: {},
+ model: {
+ max_lines_legend: 2,
+ truncate_legend: 0,
+ },
+ onChange: jest.fn(),
+ } as unknown) as PanelConfigProps;
+ const wrapper = shallow();
+ wrapper.instance().setState({ selectedTab: 'options' });
+ expect(
+ wrapper.find('[data-test-subj="timeSeriesEditorDataTruncateLegendSwitch"]').prop('value')
+ ).toEqual(0);
+ });
+
+ it('disables the max lines number input if the truncate legend switch is off', () => {
+ const props = ({
+ fields: {},
+ model: {
+ max_lines_legend: 2,
+ truncate_legend: 0,
+ },
+ onChange: jest.fn(),
+ } as unknown) as PanelConfigProps;
+ const wrapper = shallow();
+ wrapper.instance().setState({ selectedTab: 'options' });
+ expect(
+ wrapper.find('[data-test-subj="timeSeriesEditorDataMaxLegendLines"]').prop('disabled')
+ ).toEqual(true);
+ });
+});
diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx
index cdad8c1aeff4b..25e6c7906d831 100644
--- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx
+++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx
@@ -23,6 +23,7 @@ import {
EuiFieldText,
EuiTitle,
EuiHorizontalRule,
+ EuiFieldNumber,
} from '@elastic/eui';
// @ts-expect-error not typed yet
@@ -102,6 +103,9 @@ const legendPositionOptions = [
},
];
+const MAX_TRUNCATE_LINES = 5;
+const MIN_TRUNCATE_LINES = 1;
+
export class TimeseriesPanelConfig extends Component<
PanelConfigProps,
{ selectedTab: PANEL_CONFIG_TABS }
@@ -344,7 +348,7 @@ export class TimeseriesPanelConfig extends Component<
/>
-
+
-
-
+
-
+
-
+
+
+
+
-
+
+
+
-
-
-
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+ {
+ const val = Number(e.target.value);
+ this.props.onChange({
+ max_lines_legend: Math.min(
+ MAX_TRUNCATE_LINES,
+ Math.max(val, MIN_TRUNCATE_LINES)
+ ),
+ });
+ }}
+ />
+
+
+
+
+
+
+
diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js
index 097b0a7b5e332..d9440804701b2 100644
--- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js
+++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js
@@ -238,6 +238,8 @@ class TimeseriesVisualization extends Component {
showGrid={Boolean(model.show_grid)}
legend={Boolean(model.show_legend)}
legendPosition={model.legend_position}
+ truncateLegend={Boolean(model.truncate_legend)}
+ maxLegendLines={model.max_lines_legend}
tooltipMode={model.tooltip_mode}
xAxisFormatter={this.xAxisFormatter(interval)}
annotations={this.prepareAnnotations()}
diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js
index a818d1d5843de..b470352eec56a 100644
--- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js
+++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js
@@ -56,6 +56,8 @@ export const TimeSeries = ({
showGrid,
legend,
legendPosition,
+ truncateLegend,
+ maxLegendLines,
tooltipMode,
series,
yAxis,
@@ -172,6 +174,9 @@ export const TimeSeries = ({
background: {
color: backgroundColor,
},
+ legend: {
+ labelOptions: { maxLines: truncateLegend ? maxLegendLines ?? 1 : 0 },
+ },
},
chartTheme,
]}
@@ -216,6 +221,7 @@ export const TimeSeries = ({
lines,
data,
hideInLegend,
+ truncateLegend,
xScaleType,
yScaleType,
groupId,
@@ -249,6 +255,7 @@ export const TimeSeries = ({
name={getValueOrEmpty(seriesName)}
data={data}
hideInLegend={hideInLegend}
+ truncateLegend={truncateLegend}
bars={bars}
color={finalColor}
stackAccessors={stackAccessors}
@@ -274,6 +281,7 @@ export const TimeSeries = ({
name={getValueOrEmpty(seriesName)}
data={data}
hideInLegend={hideInLegend}
+ truncateLegend={truncateLegend}
lines={lines}
color={finalColor}
stackAccessors={stackAccessors}
@@ -336,6 +344,8 @@ TimeSeries.propTypes = {
showGrid: PropTypes.bool,
legend: PropTypes.bool,
legendPosition: PropTypes.string,
+ truncateLegend: PropTypes.bool,
+ maxLegendLines: PropTypes.number,
series: PropTypes.array,
yAxis: PropTypes.array,
onBrush: PropTypes.func,
diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts
index d639604c7cd29..b68812b9828e3 100644
--- a/src/plugins/vis_type_timeseries/public/metrics_type.ts
+++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts
@@ -93,6 +93,8 @@ export const metricsVisDefinition: VisTypeDefinition<
axis_formatter: 'number',
axis_scale: 'normal',
show_legend: 1,
+ truncate_legend: 1,
+ max_lines_legend: 1,
show_grid: 1,
tooltip_mode: TOOLTIP_MODES.SHOW_ALL,
drop_last_bucket: 0,
diff --git a/src/plugins/vis_type_vislib/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_type_vislib/public/__snapshots__/to_ast.test.ts.snap
index 8b720568c4d2c..233940d97d38a 100644
--- a/src/plugins/vis_type_vislib/public/__snapshots__/to_ast.test.ts.snap
+++ b/src/plugins/vis_type_vislib/public/__snapshots__/to_ast.test.ts.snap
@@ -8,7 +8,7 @@ Object {
"area",
],
"visConfig": Array [
- "{\\"type\\":\\"area\\",\\"grid\\":{\\"categoryLines\\":false,\\"style\\":{\\"color\\":\\"#eee\\"}},\\"categoryAxes\\":[{\\"id\\":\\"CategoryAxis-1\\",\\"type\\":\\"category\\",\\"position\\":\\"bottom\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\"},\\"labels\\":{\\"show\\":true,\\"truncate\\":100},\\"title\\":{}}],\\"valueAxes\\":[{\\"id\\":\\"ValueAxis-1\\",\\"name\\":\\"LeftAxis-1\\",\\"type\\":\\"value\\",\\"position\\":\\"left\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\",\\"mode\\":\\"normal\\"},\\"labels\\":{\\"show\\":true,\\"rotate\\":0,\\"filter\\":false,\\"truncate\\":100},\\"title\\":{\\"text\\":\\"Sum of total_quantity\\"}}],\\"seriesParams\\":[{\\"show\\":\\"true\\",\\"type\\":\\"area\\",\\"mode\\":\\"stacked\\",\\"data\\":{\\"label\\":\\"Sum of total_quantity\\",\\"id\\":\\"1\\"},\\"drawLinesBetweenPoints\\":true,\\"showCircles\\":true,\\"circlesRadius\\":5,\\"interpolate\\":\\"linear\\",\\"valueAxis\\":\\"ValueAxis-1\\"}],\\"addTooltip\\":true,\\"addLegend\\":true,\\"legendPosition\\":\\"top\\",\\"times\\":[],\\"addTimeMarker\\":false,\\"thresholdLine\\":{\\"show\\":false,\\"value\\":10,\\"width\\":1,\\"style\\":\\"full\\",\\"color\\":\\"#E7664C\\"},\\"palette\\":{\\"name\\":\\"default\\"},\\"labels\\":{},\\"dimensions\\":{\\"x\\":{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"HH:mm:ss.SSS\\"}},\\"params\\":{}},\\"y\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\",\\"params\\":{\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}],\\"series\\":[{\\"accessor\\":2,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}",
+ "{\\"type\\":\\"area\\",\\"grid\\":{\\"categoryLines\\":false,\\"style\\":{\\"color\\":\\"#eee\\"}},\\"categoryAxes\\":[{\\"id\\":\\"CategoryAxis-1\\",\\"type\\":\\"category\\",\\"position\\":\\"bottom\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\"},\\"labels\\":{\\"show\\":true,\\"truncate\\":100},\\"title\\":{}}],\\"valueAxes\\":[{\\"id\\":\\"ValueAxis-1\\",\\"name\\":\\"LeftAxis-1\\",\\"type\\":\\"value\\",\\"position\\":\\"left\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\",\\"mode\\":\\"normal\\"},\\"labels\\":{\\"show\\":true,\\"rotate\\":0,\\"filter\\":false,\\"truncate\\":100},\\"title\\":{\\"text\\":\\"Sum of total_quantity\\"}}],\\"seriesParams\\":[{\\"show\\":\\"true\\",\\"type\\":\\"area\\",\\"mode\\":\\"stacked\\",\\"data\\":{\\"label\\":\\"Sum of total_quantity\\",\\"id\\":\\"1\\"},\\"drawLinesBetweenPoints\\":true,\\"showCircles\\":true,\\"circlesRadius\\":5,\\"interpolate\\":\\"linear\\",\\"valueAxis\\":\\"ValueAxis-1\\"}],\\"addTooltip\\":true,\\"addLegend\\":true,\\"legendPosition\\":\\"top\\",\\"times\\":[],\\"addTimeMarker\\":false,\\"truncateLegend\\":true,\\"maxLegendLines\\":1,\\"thresholdLine\\":{\\"show\\":false,\\"value\\":10,\\"width\\":1,\\"style\\":\\"full\\",\\"color\\":\\"#E7664C\\"},\\"palette\\":{\\"name\\":\\"default\\"},\\"labels\\":{},\\"dimensions\\":{\\"x\\":{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"HH:mm:ss.SSS\\"}},\\"params\\":{}},\\"y\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\",\\"params\\":{\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}],\\"series\\":[{\\"accessor\\":2,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}",
],
},
"getArgument": [Function],
diff --git a/src/plugins/vis_type_xy/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_type_xy/public/__snapshots__/to_ast.test.ts.snap
index 7c21e699216bc..7ee1b0d2b2053 100644
--- a/src/plugins/vis_type_xy/public/__snapshots__/to_ast.test.ts.snap
+++ b/src/plugins/vis_type_xy/public/__snapshots__/to_ast.test.ts.snap
@@ -32,6 +32,9 @@ Object {
"legendPosition": Array [
"top",
],
+ "maxLegendLines": Array [
+ 1,
+ ],
"palette": Array [
"default",
],
@@ -51,6 +54,9 @@ Object {
},
],
"times": Array [],
+ "truncateLegend": Array [
+ true,
+ ],
"type": Array [
"area",
],
diff --git a/src/plugins/vis_type_xy/public/components/xy_settings.tsx b/src/plugins/vis_type_xy/public/components/xy_settings.tsx
index 03455bae69506..2dd7d7e0a91f9 100644
--- a/src/plugins/vis_type_xy/public/components/xy_settings.tsx
+++ b/src/plugins/vis_type_xy/public/components/xy_settings.tsx
@@ -60,6 +60,8 @@ type XYSettingsProps = Pick<
legendAction?: LegendAction;
legendColorPicker: LegendColorPicker;
legendPosition: Position;
+ truncateLegend: boolean;
+ maxLegendLines: number;
};
function getValueLabelsStyling() {
@@ -93,6 +95,8 @@ export const XYSettings: FC = ({
legendAction,
legendColorPicker,
legendPosition,
+ maxLegendLines,
+ truncateLegend,
}) => {
const themeService = getThemeService();
const theme = themeService.useChartsTheme();
@@ -113,6 +117,9 @@ export const XYSettings: FC = ({
crosshair: {
...theme.crosshair,
},
+ legend: {
+ labelOptions: { maxLines: truncateLegend ? maxLegendLines ?? 1 : 0 },
+ },
axes: {
axisTitle: {
padding: {
diff --git a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.mocks.ts b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.mocks.ts
index d5e1360ced74c..e51b47bc4c7fa 100644
--- a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.mocks.ts
+++ b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.mocks.ts
@@ -426,6 +426,8 @@ export const getVis = (bucketType: string) => {
fittingFunction: 'linear',
times: [],
addTimeMarker: false,
+ maxLegendLines: 1,
+ truncateLegend: true,
radiusRatio: 9,
thresholdLine: {
show: false,
@@ -849,6 +851,8 @@ export const getStateParams = (type: string, thresholdPanelOn: boolean) => {
legendPosition: 'right',
times: [],
addTimeMarker: false,
+ maxLegendLines: 1,
+ truncateLegend: true,
detailedTooltip: true,
palette: {
type: 'palette',
diff --git a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.test.tsx b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.test.tsx
index 59c03e02ac9f4..7fedd38e4e7ec 100644
--- a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.test.tsx
+++ b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.test.tsx
@@ -105,6 +105,26 @@ describe('PointSeries Editor', function () {
});
});
+ it('not renders the long legend options if showElasticChartsOptions is false', async () => {
+ component = mountWithIntl();
+ await act(async () => {
+ expect(findTestSubject(component, 'xyLongLegendsOptions').length).toBe(0);
+ });
+ });
+
+ it('renders the long legend options if showElasticChartsOptions is true', async () => {
+ const newVisProps = ({
+ ...props,
+ extraProps: {
+ showElasticChartsOptions: true,
+ },
+ } as unknown) as PointSeriesOptionsProps;
+ component = mountWithIntl();
+ await act(async () => {
+ expect(findTestSubject(component, 'xyLongLegendsOptions').length).toBe(1);
+ });
+ });
+
it('not renders the fitting function for a bar chart', async () => {
const newVisProps = ({
...props,
diff --git a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.tsx b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.tsx
index 343976651d21e..1fd9b043e87f5 100644
--- a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.tsx
+++ b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.tsx
@@ -11,7 +11,11 @@ import { EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
-import { BasicOptions, SwitchOption } from '../../../../../../vis_default_editor/public';
+import {
+ BasicOptions,
+ SwitchOption,
+ LongLegendOptions,
+} from '../../../../../../vis_default_editor/public';
import { BUCKET_TYPES } from '../../../../../../data/public';
import { VisParams } from '../../../../types';
@@ -58,6 +62,14 @@ export function PointSeriesOptions(
+ {props.extraProps?.showElasticChartsOptions && (
+
+ )}
{vis.data.aggs!.aggs.some(
(agg) => agg.schema === 'segment' && agg.type.name === BUCKET_TYPES.DATE_HISTOGRAM
diff --git a/src/plugins/vis_type_xy/public/expression_functions/xy_vis_fn.ts b/src/plugins/vis_type_xy/public/expression_functions/xy_vis_fn.ts
index 35f3b2d7c627d..6d2b860066b07 100644
--- a/src/plugins/vis_type_xy/public/expression_functions/xy_vis_fn.ts
+++ b/src/plugins/vis_type_xy/public/expression_functions/xy_vis_fn.ts
@@ -55,6 +55,18 @@ export const visTypeXyVisFn = (): VisTypeXyExpressionFunctionDefinition => ({
defaultMessage: 'Show time marker',
}),
},
+ truncateLegend: {
+ types: ['boolean'],
+ help: i18n.translate('visTypeXy.function.args.truncateLegend.help', {
+ defaultMessage: 'Defines if the legend will be truncated or not',
+ }),
+ },
+ maxLegendLines: {
+ types: ['number'],
+ help: i18n.translate('visTypeXy.function.args.args.maxLegendLines.help', {
+ defaultMessage: 'Defines the maximum lines per legend item',
+ }),
+ },
addLegend: {
types: ['boolean'],
help: i18n.translate('visTypeXy.function.args.addLegend.help', {
@@ -225,6 +237,8 @@ export const visTypeXyVisFn = (): VisTypeXyExpressionFunctionDefinition => ({
addTooltip: args.addTooltip,
legendPosition: args.legendPosition,
addTimeMarker: args.addTimeMarker,
+ maxLegendLines: args.maxLegendLines,
+ truncateLegend: args.truncateLegend,
categoryAxes: args.categoryAxes.map((categoryAxis) => ({
...categoryAxis,
type: categoryAxis.axisType,
diff --git a/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts b/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts
index 8fafd4c723055..7fff29edfab51 100644
--- a/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts
+++ b/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts
@@ -88,6 +88,8 @@ export const sampleAreaVis = {
legendPosition: 'right',
times: [],
addTimeMarker: false,
+ truncateLegend: true,
+ maxLegendLines: 1,
thresholdLine: {
show: false,
value: 10,
@@ -255,6 +257,8 @@ export const sampleAreaVis = {
legendPosition: 'top',
times: [],
addTimeMarker: false,
+ truncateLegend: true,
+ maxLegendLines: 1,
thresholdLine: {
show: false,
value: 10,
diff --git a/src/plugins/vis_type_xy/public/to_ast.ts b/src/plugins/vis_type_xy/public/to_ast.ts
index 9fec3f99ab39b..0b1eb5262d71a 100644
--- a/src/plugins/vis_type_xy/public/to_ast.ts
+++ b/src/plugins/vis_type_xy/public/to_ast.ts
@@ -194,6 +194,8 @@ export const toExpressionAst: VisToExpressionAst = async (vis, params
type: vis.type.name as XyVisType,
chartType: vis.params.type,
addTimeMarker: vis.params.addTimeMarker,
+ truncateLegend: vis.params.truncateLegend,
+ maxLegendLines: vis.params.maxLegendLines,
addLegend: vis.params.addLegend,
addTooltip: vis.params.addTooltip,
legendPosition: vis.params.legendPosition,
diff --git a/src/plugins/vis_type_xy/public/types/param.ts b/src/plugins/vis_type_xy/public/types/param.ts
index 421690f7fc6a9..0687bd2af2cd1 100644
--- a/src/plugins/vis_type_xy/public/types/param.ts
+++ b/src/plugins/vis_type_xy/public/types/param.ts
@@ -121,6 +121,8 @@ export interface VisParams {
addTooltip: boolean;
legendPosition: Position;
addTimeMarker: boolean;
+ truncateLegend: boolean;
+ maxLegendLines: number;
categoryAxes: CategoryAxis[];
orderBucketsBySum?: boolean;
labels: Labels;
@@ -158,6 +160,8 @@ export interface XYVisConfig {
addTooltip: boolean;
legendPosition: Position;
addTimeMarker: boolean;
+ truncateLegend: boolean;
+ maxLegendLines: number;
orderBucketsBySum?: boolean;
labels: ExpressionValueLabel;
thresholdLine: ExpressionValueThresholdLine;
diff --git a/src/plugins/vis_type_xy/public/vis_component.tsx b/src/plugins/vis_type_xy/public/vis_component.tsx
index 2dffabb2ba0b9..346f6cc74a1ac 100644
--- a/src/plugins/vis_type_xy/public/vis_component.tsx
+++ b/src/plugins/vis_type_xy/public/vis_component.tsx
@@ -345,6 +345,8 @@ const VisComponent = (props: VisComponentProps) => {
/>
tr:nth-child(1)');
diff --git a/test/functional/apps/discover/_field_data.ts b/test/functional/apps/discover/_field_data.ts
index fc0c0c6a48649..27407e9a0bc4d 100644
--- a/test/functional/apps/discover/_field_data.ts
+++ b/test/functional/apps/discover/_field_data.ts
@@ -34,8 +34,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
await PageObjects.common.navigateToApp('discover');
});
- // FLAKY: https://github.com/elastic/kibana/issues/100437
- describe.skip('field data', function () {
+ describe('field data', function () {
it('search php should show the correct hit count', async function () {
const expectedHitCount = '445';
await retry.try(async function () {
diff --git a/test/functional/apps/discover/_field_data_with_fields_api.ts b/test/functional/apps/discover/_field_data_with_fields_api.ts
index 97c1e678c4a9f..666377ae7f794 100644
--- a/test/functional/apps/discover/_field_data_with_fields_api.ts
+++ b/test/functional/apps/discover/_field_data_with_fields_api.ts
@@ -34,8 +34,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
await PageObjects.common.navigateToApp('discover');
});
- // FLAKY: https://github.com/elastic/kibana/issues/103389
- describe.skip('field data', function () {
+ describe('field data', function () {
it('search php should show the correct hit count', async function () {
const expectedHitCount = '445';
await retry.try(async function () {
diff --git a/test/functional/apps/management/_runtime_fields.js b/test/functional/apps/management/_runtime_fields.js
index 9a051bbdef6eb..745a3f9b079a4 100644
--- a/test/functional/apps/management/_runtime_fields.js
+++ b/test/functional/apps/management/_runtime_fields.js
@@ -17,7 +17,8 @@ export default function ({ getService, getPageObjects }) {
const PageObjects = getPageObjects(['settings']);
const testSubjects = getService('testSubjects');
- describe('runtime fields', function () {
+ // FLAKY: https://github.com/elastic/kibana/issues/95376
+ describe.skip('runtime fields', function () {
this.tags(['skipFirefox']);
before(async function () {
diff --git a/test/functional/apps/saved_objects_management/edit_saved_object.ts b/test/functional/apps/saved_objects_management/edit_saved_object.ts
index a90e927416685..f4bf45c0b7f70 100644
--- a/test/functional/apps/saved_objects_management/edit_saved_object.ts
+++ b/test/functional/apps/saved_objects_management/edit_saved_object.ts
@@ -53,6 +53,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await button.focus();
await delay(10);
await button.click();
+ // Allow some time for the transition/animations to occur before assuming the click is done
+ await delay(10);
};
describe('saved objects edition page', () => {
diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts
index a4d8f884e1824..ae1b4fbf3179a 100644
--- a/test/functional/page_objects/discover_page.ts
+++ b/test/functional/page_objects/discover_page.ts
@@ -353,17 +353,39 @@ export class DiscoverPageObject extends FtrService {
public async clickFieldListItemAdd(field: string) {
// a filter check may make sense here, but it should be properly handled to make
// it work with the _score and _source fields as well
+ if (await this.isFieldSelected(field)) {
+ return;
+ }
await this.clickFieldListItemToggle(field);
+ const isLegacyDefault = await this.useLegacyTable();
+ if (isLegacyDefault) {
+ await this.retry.waitFor(`field ${field} to be added to classic table`, async () => {
+ return await this.testSubjects.exists(`docTableHeader-${field}`);
+ });
+ } else {
+ await this.retry.waitFor(`field ${field} to be added to new table`, async () => {
+ return await this.testSubjects.exists(`dataGridHeaderCell-${field}`);
+ });
+ }
}
- public async clickFieldListItemRemove(field: string) {
+ public async isFieldSelected(field: string) {
if (!(await this.testSubjects.exists('fieldList-selected'))) {
- return;
+ return false;
}
const selectedList = await this.testSubjects.find('fieldList-selected');
- if (await this.testSubjects.descendantExists(`field-${field}`, selectedList)) {
- await this.clickFieldListItemToggle(field);
+ return await this.testSubjects.descendantExists(`field-${field}`, selectedList);
+ }
+
+ public async clickFieldListItemRemove(field: string) {
+ if (
+ !(await this.testSubjects.exists('fieldList-selected')) ||
+ !(await this.isFieldSelected(field))
+ ) {
+ return;
}
+
+ await this.clickFieldListItemToggle(field);
}
public async clickFieldListItemVisualize(fieldName: string) {
diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts
index 1271fe5108f56..cf3a692d1622e 100644
--- a/test/functional/page_objects/visualize_page.ts
+++ b/test/functional/page_objects/visualize_page.ts
@@ -310,6 +310,7 @@ export class VisualizePageObject extends FtrService {
if (navigateToVisualize) {
await this.clickLoadSavedVisButton();
}
+ await this.listingTable.searchForItemWithName(vizName);
await this.openSavedVisualization(vizName);
}
diff --git a/test/interpreter_functional/config.ts b/test/interpreter_functional/config.ts
index c0ec982fb98b6..3f9c846a51429 100644
--- a/test/interpreter_functional/config.ts
+++ b/test/interpreter_functional/config.ts
@@ -20,6 +20,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
);
return {
+ rootTags: ['runOutsideOfCiGroups'],
testFiles: [require.resolve('./test_suites/run_pipeline')],
services: functionalConfig.get('services'),
pageObjects: functionalConfig.get('pageObjects'),
diff --git a/test/plugin_functional/config.ts b/test/plugin_functional/config.ts
index e371518ce7fc7..8ac1633e61e49 100644
--- a/test/plugin_functional/config.ts
+++ b/test/plugin_functional/config.ts
@@ -20,6 +20,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
);
return {
+ rootTags: ['runOutsideOfCiGroups'],
testFiles: [
require.resolve('./test_suites/usage_collection'),
require.resolve('./test_suites/telemetry'),
diff --git a/test/scripts/jenkins_build_kibana.sh b/test/scripts/jenkins_build_kibana.sh
index 4b4dcaa64cd32..0705cb1062d8e 100755
--- a/test/scripts/jenkins_build_kibana.sh
+++ b/test/scripts/jenkins_build_kibana.sh
@@ -11,24 +11,7 @@ fi
export KBN_NP_PLUGINS_BUILT=true
echo " -> Ensuring all functional tests are in a ciGroup"
-node scripts/ensure_all_tests_in_ci_group;
-
-echo " -> Ensuring all x-pack functional tests are in a ciGroup"
-node x-pack/scripts/functional_tests --assert-none-excluded \
- --include-tag ciGroup1 \
- --include-tag ciGroup2 \
- --include-tag ciGroup3 \
- --include-tag ciGroup4 \
- --include-tag ciGroup5 \
- --include-tag ciGroup6 \
- --include-tag ciGroup7 \
- --include-tag ciGroup8 \
- --include-tag ciGroup9 \
- --include-tag ciGroup10 \
- --include-tag ciGroup11 \
- --include-tag ciGroup12 \
- --include-tag ciGroup13 \
- --include-tag ciGroupDocker
+node scripts/ensure_all_tests_in_ci_group
# Do not build kibana for code coverage run
if [[ -z "$CODE_COVERAGE" ]] ; then
diff --git a/test/scripts/jenkins_code_coverage.sh b/test/scripts/jenkins_code_coverage.sh
index 98805e1209ec9..0931da5f9c4af 100755
--- a/test/scripts/jenkins_code_coverage.sh
+++ b/test/scripts/jenkins_code_coverage.sh
@@ -11,21 +11,4 @@ fi
export KBN_NP_PLUGINS_BUILT=true
echo " -> Ensuring all functional tests are in a ciGroup"
-node scripts/ensure_all_tests_in_ci_group;
-
-echo " -> Ensuring all x-pack functional tests are in a ciGroup"
-node x-pack/scripts/functional_tests --assert-none-excluded \
---include-tag ciGroup1 \
---include-tag ciGroup2 \
---include-tag ciGroup3 \
---include-tag ciGroup4 \
---include-tag ciGroup5 \
---include-tag ciGroup6 \
---include-tag ciGroup7 \
---include-tag ciGroup8 \
---include-tag ciGroup9 \
---include-tag ciGroup10 \
---include-tag ciGroup11 \
---include-tag ciGroup12 \
---include-tag ciGroup13 \
---include-tag ciGroupDocker
+node scripts/ensure_all_tests_in_ci_group
diff --git a/x-pack/plugins/alerting/common/alert.ts b/x-pack/plugins/alerting/common/alert.ts
index 64c759752faec..1274e7b95b114 100644
--- a/x-pack/plugins/alerting/common/alert.ts
+++ b/x-pack/plugins/alerting/common/alert.ts
@@ -5,7 +5,11 @@
* 2.0.
*/
-import { SavedObjectAttribute, SavedObjectAttributes } from 'kibana/server';
+import {
+ SavedObjectAttribute,
+ SavedObjectAttributes,
+ SavedObjectsResolveResponse,
+} from 'kibana/server';
import { AlertNotifyWhenType } from './alert_notify_when_type';
export type AlertTypeState = Record;
@@ -76,6 +80,8 @@ export interface Alert {
}
export type SanitizedAlert = Omit, 'apiKey'>;
+export type ResolvedSanitizedRule = SanitizedAlert &
+ Omit;
export type SanitizedRuleConfig = Pick<
SanitizedAlert,
diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts
index ad1c97efe2334..c1c7eae45109e 100644
--- a/x-pack/plugins/alerting/server/routes/index.ts
+++ b/x-pack/plugins/alerting/server/routes/index.ts
@@ -22,6 +22,7 @@ import { findRulesRoute } from './find_rules';
import { getRuleAlertSummaryRoute } from './get_rule_alert_summary';
import { getRuleStateRoute } from './get_rule_state';
import { healthRoute } from './health';
+import { resolveRuleRoute } from './resolve_rule';
import { ruleTypesRoute } from './rule_types';
import { muteAllRuleRoute } from './mute_all_rule';
import { muteAlertRoute } from './mute_alert';
@@ -42,6 +43,7 @@ export function defineRoutes(opts: RouteOptions) {
defineLegacyRoutes(opts);
createRuleRoute(opts);
getRuleRoute(router, licenseState);
+ resolveRuleRoute(router, licenseState);
updateRuleRoute(router, licenseState);
deleteRuleRoute(router, licenseState);
aggregateRulesRoute(router, licenseState);
diff --git a/x-pack/plugins/alerting/server/routes/resolve_rule.test.ts b/x-pack/plugins/alerting/server/routes/resolve_rule.test.ts
new file mode 100644
index 0000000000000..b03369a74b865
--- /dev/null
+++ b/x-pack/plugins/alerting/server/routes/resolve_rule.test.ts
@@ -0,0 +1,182 @@
+/*
+ * 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 { pick } from 'lodash';
+import { resolveRuleRoute } from './resolve_rule';
+import { httpServiceMock } from 'src/core/server/mocks';
+import { licenseStateMock } from '../lib/license_state.mock';
+import { verifyApiAccess } from '../lib/license_api_access';
+import { mockHandlerArguments } from './_mock_handler_arguments';
+import { rulesClientMock } from '../rules_client.mock';
+import { ResolvedSanitizedRule } from '../types';
+import { AsApiContract } from './lib';
+
+const rulesClient = rulesClientMock.create();
+jest.mock('../lib/license_api_access.ts', () => ({
+ verifyApiAccess: jest.fn(),
+}));
+
+beforeEach(() => {
+ jest.resetAllMocks();
+});
+
+describe('resolveRuleRoute', () => {
+ const mockedRule: ResolvedSanitizedRule<{
+ bar: boolean;
+ }> = {
+ id: '1',
+ alertTypeId: '1',
+ schedule: { interval: '10s' },
+ params: {
+ bar: true,
+ },
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ actions: [
+ {
+ group: 'default',
+ id: '2',
+ actionTypeId: 'test',
+ params: {
+ foo: true,
+ },
+ },
+ ],
+ consumer: 'bar',
+ name: 'abc',
+ tags: ['foo'],
+ enabled: true,
+ muteAll: false,
+ notifyWhen: 'onActionGroupChange',
+ createdBy: '',
+ updatedBy: '',
+ apiKeyOwner: '',
+ throttle: '30s',
+ mutedInstanceIds: [],
+ executionStatus: {
+ status: 'unknown',
+ lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
+ },
+ outcome: 'aliasMatch',
+ alias_target_id: '2',
+ };
+
+ const resolveResult: AsApiContract> = {
+ ...pick(
+ mockedRule,
+ 'consumer',
+ 'name',
+ 'schedule',
+ 'tags',
+ 'params',
+ 'throttle',
+ 'enabled',
+ 'alias_target_id'
+ ),
+ rule_type_id: mockedRule.alertTypeId,
+ notify_when: mockedRule.notifyWhen,
+ mute_all: mockedRule.muteAll,
+ created_by: mockedRule.createdBy,
+ updated_by: mockedRule.updatedBy,
+ api_key_owner: mockedRule.apiKeyOwner,
+ muted_alert_ids: mockedRule.mutedInstanceIds,
+ created_at: mockedRule.createdAt,
+ updated_at: mockedRule.updatedAt,
+ id: mockedRule.id,
+ execution_status: {
+ status: mockedRule.executionStatus.status,
+ last_execution_date: mockedRule.executionStatus.lastExecutionDate,
+ },
+ actions: [
+ {
+ group: mockedRule.actions[0].group,
+ id: mockedRule.actions[0].id,
+ params: mockedRule.actions[0].params,
+ connector_type_id: mockedRule.actions[0].actionTypeId,
+ },
+ ],
+ outcome: 'aliasMatch',
+ };
+
+ it('resolves a rule with proper parameters', async () => {
+ const licenseState = licenseStateMock.create();
+ const router = httpServiceMock.createRouter();
+
+ resolveRuleRoute(router, licenseState);
+ const [config, handler] = router.get.mock.calls[0];
+
+ expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rule/{id}/_resolve"`);
+
+ rulesClient.resolve.mockResolvedValueOnce(mockedRule);
+
+ const [context, req, res] = mockHandlerArguments(
+ { rulesClient },
+ {
+ params: { id: '1' },
+ },
+ ['ok']
+ );
+ await handler(context, req, res);
+
+ expect(rulesClient.resolve).toHaveBeenCalledTimes(1);
+ expect(rulesClient.resolve.mock.calls[0][0].id).toEqual('1');
+
+ expect(res.ok).toHaveBeenCalledWith({
+ body: resolveResult,
+ });
+ });
+
+ it('ensures the license allows resolving rules', async () => {
+ const licenseState = licenseStateMock.create();
+ const router = httpServiceMock.createRouter();
+
+ resolveRuleRoute(router, licenseState);
+
+ const [, handler] = router.get.mock.calls[0];
+
+ rulesClient.resolve.mockResolvedValueOnce(mockedRule);
+
+ const [context, req, res] = mockHandlerArguments(
+ { rulesClient },
+ {
+ params: { id: '1' },
+ },
+ ['ok']
+ );
+
+ await handler(context, req, res);
+
+ expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
+ });
+
+ it('ensures the license check prevents getting rules', async () => {
+ const licenseState = licenseStateMock.create();
+ const router = httpServiceMock.createRouter();
+
+ (verifyApiAccess as jest.Mock).mockImplementation(() => {
+ throw new Error('OMG');
+ });
+
+ resolveRuleRoute(router, licenseState);
+
+ const [, handler] = router.get.mock.calls[0];
+
+ rulesClient.resolve.mockResolvedValueOnce(mockedRule);
+
+ const [context, req, res] = mockHandlerArguments(
+ { rulesClient },
+ {
+ params: { id: '1' },
+ },
+ ['ok']
+ );
+
+ expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: OMG]`);
+
+ expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
+ });
+});
diff --git a/x-pack/plugins/alerting/server/routes/resolve_rule.ts b/x-pack/plugins/alerting/server/routes/resolve_rule.ts
new file mode 100644
index 0000000000000..011d28780e718
--- /dev/null
+++ b/x-pack/plugins/alerting/server/routes/resolve_rule.ts
@@ -0,0 +1,84 @@
+/*
+ * 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 { omit } from 'lodash';
+import { schema } from '@kbn/config-schema';
+import { IRouter } from 'kibana/server';
+import { ILicenseState } from '../lib';
+import { verifyAccessAndContext, RewriteResponseCase } from './lib';
+import {
+ AlertTypeParams,
+ AlertingRequestHandlerContext,
+ INTERNAL_BASE_ALERTING_API_PATH,
+ ResolvedSanitizedRule,
+} from '../types';
+
+const paramSchema = schema.object({
+ id: schema.string(),
+});
+
+const rewriteBodyRes: RewriteResponseCase> = ({
+ alertTypeId,
+ createdBy,
+ updatedBy,
+ createdAt,
+ updatedAt,
+ apiKeyOwner,
+ notifyWhen,
+ muteAll,
+ mutedInstanceIds,
+ executionStatus,
+ actions,
+ scheduledTaskId,
+ ...rest
+}) => ({
+ ...rest,
+ rule_type_id: alertTypeId,
+ created_by: createdBy,
+ updated_by: updatedBy,
+ created_at: createdAt,
+ updated_at: updatedAt,
+ api_key_owner: apiKeyOwner,
+ notify_when: notifyWhen,
+ mute_all: muteAll,
+ muted_alert_ids: mutedInstanceIds,
+ scheduled_task_id: scheduledTaskId,
+ execution_status: executionStatus && {
+ ...omit(executionStatus, 'lastExecutionDate'),
+ last_execution_date: executionStatus.lastExecutionDate,
+ },
+ actions: actions.map(({ group, id, actionTypeId, params }) => ({
+ group,
+ id,
+ params,
+ connector_type_id: actionTypeId,
+ })),
+});
+
+export const resolveRuleRoute = (
+ router: IRouter,
+ licenseState: ILicenseState
+) => {
+ router.get(
+ {
+ path: `${INTERNAL_BASE_ALERTING_API_PATH}/rule/{id}/_resolve`,
+ validate: {
+ params: paramSchema,
+ },
+ },
+ router.handleLegacyErrors(
+ verifyAccessAndContext(licenseState, async function (context, req, res) {
+ const rulesClient = context.alerting.getRulesClient();
+ const { id } = req.params;
+ const rule = await rulesClient.resolve({ id });
+ return res.ok({
+ body: rewriteBodyRes(rule),
+ });
+ })
+ )
+ );
+};
diff --git a/x-pack/plugins/alerting/server/rules_client.mock.ts b/x-pack/plugins/alerting/server/rules_client.mock.ts
index 4bd197e51a5da..438331a1cd580 100644
--- a/x-pack/plugins/alerting/server/rules_client.mock.ts
+++ b/x-pack/plugins/alerting/server/rules_client.mock.ts
@@ -16,6 +16,7 @@ const createRulesClientMock = () => {
aggregate: jest.fn(),
create: jest.fn(),
get: jest.fn(),
+ resolve: jest.fn(),
getAlertState: jest.fn(),
find: jest.fn(),
delete: jest.fn(),
diff --git a/x-pack/plugins/alerting/server/rules_client/audit_events.ts b/x-pack/plugins/alerting/server/rules_client/audit_events.ts
index f04b7c3701974..5f6122458ddaf 100644
--- a/x-pack/plugins/alerting/server/rules_client/audit_events.ts
+++ b/x-pack/plugins/alerting/server/rules_client/audit_events.ts
@@ -11,6 +11,7 @@ import { AuditEvent } from '../../../security/server';
export enum RuleAuditAction {
CREATE = 'rule_create',
GET = 'rule_get',
+ RESOLVE = 'rule_resolve',
UPDATE = 'rule_update',
UPDATE_API_KEY = 'rule_update_api_key',
ENABLE = 'rule_enable',
@@ -28,6 +29,7 @@ type VerbsTuple = [string, string, string];
const eventVerbs: Record = {
rule_create: ['create', 'creating', 'created'],
rule_get: ['access', 'accessing', 'accessed'],
+ rule_resolve: ['access', 'accessing', 'accessed'],
rule_update: ['update', 'updating', 'updated'],
rule_update_api_key: ['update API key of', 'updating API key of', 'updated API key of'],
rule_enable: ['enable', 'enabling', 'enabled'],
@@ -43,6 +45,7 @@ const eventVerbs: Record = {
const eventTypes: Record = {
rule_create: 'creation',
rule_get: 'access',
+ rule_resolve: 'access',
rule_update: 'change',
rule_update_api_key: 'change',
rule_enable: 'change',
diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts
index a079a52448e2d..486cf086b4a73 100644
--- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts
+++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts
@@ -5,6 +5,7 @@
* 2.0.
*/
+import Semver from 'semver';
import Boom from '@hapi/boom';
import { omit, isEqual, map, uniq, pick, truncate, trim } from 'lodash';
import { i18n } from '@kbn/i18n';
@@ -33,6 +34,7 @@ import {
AlertExecutionStatusValues,
AlertNotifyWhenType,
AlertTypeParams,
+ ResolvedSanitizedRule,
} from '../types';
import {
validateAlertTypeParams,
@@ -296,11 +298,13 @@ export class RulesClient {
);
const createTime = Date.now();
+ const legacyId = Semver.lt(this.kibanaVersion, '8.0.0') ? id : null;
const notifyWhen = getAlertNotifyWhenType(data.notifyWhen, data.throttle);
const rawAlert: RawAlert = {
...data,
...this.apiKeyAsAlertAttributes(createdAPIKey, username),
+ legacyId,
actions,
createdBy: username,
updatedBy: username,
@@ -411,6 +415,52 @@ export class RulesClient {
);
}
+ public async resolve({
+ id,
+ }: {
+ id: string;
+ }): Promise> {
+ const {
+ saved_object: result,
+ ...resolveResponse
+ } = await this.unsecuredSavedObjectsClient.resolve('alert', id);
+ try {
+ await this.authorization.ensureAuthorized({
+ ruleTypeId: result.attributes.alertTypeId,
+ consumer: result.attributes.consumer,
+ operation: ReadOperations.Get,
+ entity: AlertingAuthorizationEntity.Rule,
+ });
+ } catch (error) {
+ this.auditLogger?.log(
+ ruleAuditEvent({
+ action: RuleAuditAction.RESOLVE,
+ savedObject: { type: 'alert', id },
+ error,
+ })
+ );
+ throw error;
+ }
+ this.auditLogger?.log(
+ ruleAuditEvent({
+ action: RuleAuditAction.RESOLVE,
+ savedObject: { type: 'alert', id },
+ })
+ );
+
+ const rule = this.getAlertFromRaw(
+ result.id,
+ result.attributes.alertTypeId,
+ result.attributes,
+ result.references
+ );
+
+ return {
+ ...rule,
+ ...resolveResponse,
+ };
+ }
+
public async getAlertState({ id }: { id: string }): Promise {
const alert = await this.get({ id });
await this.authorization.ensureAuthorized({
@@ -1492,36 +1542,29 @@ export class RulesClient {
notifyWhen,
scheduledTaskId,
params,
- ...rawAlert
+ legacyId, // exclude from result because it is an internal variable
+ executionStatus,
+ schedule,
+ actions,
+ ...partialRawAlert
}: Partial,
references: SavedObjectReference[] | undefined
): PartialAlert {
- // Not the prettiest code here, but if we want to use most of the
- // alert fields from the rawAlert using `...rawAlert` kind of access, we
- // need to specifically delete the executionStatus as it's a different type
- // in RawAlert and Alert. Probably next time we need to do something similar
- // here, we should look at redesigning the implementation of this method.
- const rawAlertWithoutExecutionStatus: Partial> = {
- ...rawAlert,
- };
- delete rawAlertWithoutExecutionStatus.executionStatus;
- const executionStatus = alertExecutionStatusFromRaw(this.logger, id, rawAlert.executionStatus);
-
return {
id,
notifyWhen,
- ...rawAlertWithoutExecutionStatus,
+ ...partialRawAlert,
// we currently only support the Interval Schedule type
// Once we support additional types, this type signature will likely change
- schedule: rawAlert.schedule as IntervalSchedule,
- actions: rawAlert.actions
- ? this.injectReferencesIntoActions(id, rawAlert.actions, references || [])
- : [],
+ schedule: schedule as IntervalSchedule,
+ actions: actions ? this.injectReferencesIntoActions(id, actions, references || []) : [],
params: this.injectReferencesIntoParams(id, ruleType, params, references || []) as Params,
...(updatedAt ? { updatedAt: new Date(updatedAt) } : {}),
...(createdAt ? { createdAt: new Date(createdAt) } : {}),
...(scheduledTaskId ? { scheduledTaskId } : {}),
- ...(executionStatus ? { executionStatus } : {}),
+ ...(executionStatus
+ ? { executionStatus: alertExecutionStatusFromRaw(this.logger, id, executionStatus) }
+ : {}),
};
}
diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts
index 944dcc29ff933..001604d68c46b 100644
--- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts
+++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts
@@ -35,7 +35,7 @@ const authorization = alertingAuthorizationMock.create();
const actionsAuthorization = actionsAuthorizationMock.create();
const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
-const kibanaVersion = 'v7.10.0';
+const kibanaVersion = 'v8.0.0';
const rulesClientParams: jest.Mocked = {
taskManager,
ruleTypeRegistry,
@@ -116,6 +116,19 @@ describe('create()', () => {
isPreconfigured: false,
},
]);
+ taskManager.schedule.mockResolvedValue({
+ id: 'task-123',
+ taskType: 'alerting:123',
+ scheduledAt: new Date(),
+ attempts: 1,
+ status: TaskStatus.Idle,
+ runAt: new Date(),
+ startedAt: null,
+ retryAt: null,
+ state: {},
+ params: {},
+ ownerId: null,
+ });
rulesClientParams.getActionsClient.mockResolvedValue(actionsClient);
});
@@ -154,19 +167,6 @@ describe('create()', () => {
},
],
});
- taskManager.schedule.mockResolvedValueOnce({
- id: 'task-123',
- taskType: 'alerting:123',
- scheduledAt: new Date(),
- attempts: 1,
- status: TaskStatus.Idle,
- runAt: new Date(),
- startedAt: null,
- retryAt: null,
- state: {},
- params: {},
- ownerId: null,
- });
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'alert',
@@ -319,19 +319,6 @@ describe('create()', () => {
},
],
});
- taskManager.schedule.mockResolvedValueOnce({
- id: 'task-123',
- taskType: 'alerting:123',
- scheduledAt: new Date(),
- attempts: 1,
- status: TaskStatus.Idle,
- runAt: new Date(),
- startedAt: null,
- retryAt: null,
- state: {},
- params: {},
- ownerId: null,
- });
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'alert',
@@ -418,8 +405,9 @@ describe('create()', () => {
"lastExecutionDate": "2019-02-12T21:01:22.479Z",
"status": "pending",
},
+ "legacyId": null,
"meta": Object {
- "versionApiKeyLastmodified": "v7.10.0",
+ "versionApiKeyLastmodified": "v8.0.0",
},
"muteAll": false,
"mutedInstanceIds": Array [],
@@ -524,19 +512,6 @@ describe('create()', () => {
},
],
});
- taskManager.schedule.mockResolvedValueOnce({
- id: 'task-123',
- taskType: 'alerting:123',
- scheduledAt: new Date(),
- attempts: 1,
- status: TaskStatus.Idle,
- runAt: new Date(),
- startedAt: null,
- retryAt: null,
- state: {},
- params: {},
- ownerId: null,
- });
const result = await rulesClient.create({ data, options: { id: '123' } });
expect(result.id).toEqual('123');
expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(`
@@ -553,6 +528,99 @@ describe('create()', () => {
`);
});
+ test('sets legacyId when kibanaVersion is < 8.0.0', async () => {
+ const customrulesClient = new RulesClient({
+ ...rulesClientParams,
+ kibanaVersion: 'v7.10.0',
+ });
+ const data = getMockData();
+ const createdAttributes = {
+ ...data,
+ legacyId: '123',
+ alertTypeId: '123',
+ schedule: { interval: '10s' },
+ params: {
+ bar: true,
+ },
+ createdAt: '2019-02-12T21:01:22.479Z',
+ createdBy: 'elastic',
+ updatedBy: 'elastic',
+ updatedAt: '2019-02-12T21:01:22.479Z',
+ muteAll: false,
+ mutedInstanceIds: [],
+ actions: [
+ {
+ group: 'default',
+ actionRef: 'action_0',
+ actionTypeId: 'test',
+ params: {
+ foo: true,
+ },
+ },
+ ],
+ };
+ unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
+ id: '123',
+ type: 'alert',
+ attributes: createdAttributes,
+ references: [
+ {
+ name: 'action_0',
+ type: 'action',
+ id: '1',
+ },
+ ],
+ });
+ const result = await customrulesClient.create({ data, options: { id: '123' } });
+ expect(result.id).toEqual('123');
+ expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(`
+ Object {
+ "actions": Array [
+ Object {
+ "actionRef": "action_0",
+ "actionTypeId": "test",
+ "group": "default",
+ "params": Object {
+ "foo": true,
+ },
+ },
+ ],
+ "alertTypeId": "123",
+ "apiKey": null,
+ "apiKeyOwner": null,
+ "consumer": "bar",
+ "createdAt": "2019-02-12T21:01:22.479Z",
+ "createdBy": "elastic",
+ "enabled": true,
+ "executionStatus": Object {
+ "error": null,
+ "lastExecutionDate": "2019-02-12T21:01:22.479Z",
+ "status": "pending",
+ },
+ "legacyId": "123",
+ "meta": Object {
+ "versionApiKeyLastmodified": "v7.10.0",
+ },
+ "muteAll": false,
+ "mutedInstanceIds": Array [],
+ "name": "abc",
+ "notifyWhen": "onActiveAlert",
+ "params": Object {
+ "bar": true,
+ },
+ "schedule": Object {
+ "interval": "10s",
+ },
+ "tags": Array [
+ "foo",
+ ],
+ "throttle": null,
+ "updatedAt": "2019-02-12T21:01:22.479Z",
+ "updatedBy": "elastic",
+ }
+ `);
+ });
+
test('creates an alert with multiple actions', async () => {
const data = getMockData({
actions: [
@@ -669,19 +737,6 @@ describe('create()', () => {
},
],
});
- taskManager.schedule.mockResolvedValueOnce({
- id: 'task-123',
- taskType: 'alerting:123',
- scheduledAt: new Date(),
- attempts: 1,
- status: TaskStatus.Idle,
- runAt: new Date(),
- startedAt: null,
- retryAt: null,
- state: {},
- params: {},
- ownerId: null,
- });
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'alert',
@@ -878,19 +933,6 @@ describe('create()', () => {
},
],
});
- taskManager.schedule.mockResolvedValueOnce({
- id: 'task-123',
- taskType: 'alerting:123',
- scheduledAt: new Date(),
- attempts: 1,
- status: TaskStatus.Idle,
- runAt: new Date(),
- startedAt: null,
- retryAt: null,
- state: {},
- params: {},
- ownerId: null,
- });
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'alert',
@@ -916,12 +958,13 @@ describe('create()', () => {
createdAt: '2019-02-12T21:01:22.479Z',
createdBy: 'elastic',
enabled: true,
+ legacyId: null,
executionStatus: {
error: null,
lastExecutionDate: '2019-02-12T21:01:22.479Z',
status: 'pending',
},
- meta: { versionApiKeyLastmodified: 'v7.10.0' },
+ meta: { versionApiKeyLastmodified: kibanaVersion },
muteAll: false,
mutedInstanceIds: [],
name: 'abc',
@@ -1055,19 +1098,6 @@ describe('create()', () => {
},
],
});
- taskManager.schedule.mockResolvedValueOnce({
- id: 'task-123',
- taskType: 'alerting:123',
- scheduledAt: new Date(),
- attempts: 1,
- status: TaskStatus.Idle,
- runAt: new Date(),
- startedAt: null,
- retryAt: null,
- state: {},
- params: {},
- ownerId: null,
- });
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'alert',
@@ -1089,6 +1119,7 @@ describe('create()', () => {
alertTypeId: '123',
apiKey: null,
apiKeyOwner: null,
+ legacyId: null,
consumer: 'bar',
createdAt: '2019-02-12T21:01:22.479Z',
createdBy: 'elastic',
@@ -1098,7 +1129,7 @@ describe('create()', () => {
lastExecutionDate: '2019-02-12T21:01:22.479Z',
status: 'pending',
},
- meta: { versionApiKeyLastmodified: 'v7.10.0' },
+ meta: { versionApiKeyLastmodified: kibanaVersion },
muteAll: false,
mutedInstanceIds: [],
name: 'abc',
@@ -1189,19 +1220,6 @@ describe('create()', () => {
},
],
});
- taskManager.schedule.mockResolvedValueOnce({
- id: 'task-123',
- taskType: 'alerting:123',
- scheduledAt: new Date(),
- attempts: 1,
- status: TaskStatus.Idle,
- runAt: new Date(),
- startedAt: null,
- retryAt: null,
- state: {},
- params: {},
- ownerId: null,
- });
await rulesClient.create({ data });
expect(rulesClientParams.createAPIKey).toHaveBeenCalledWith('Alerting: 123/my alert name');
@@ -1246,19 +1264,6 @@ describe('create()', () => {
},
],
});
- taskManager.schedule.mockResolvedValueOnce({
- id: 'task-123',
- taskType: 'alerting:123',
- scheduledAt: new Date(),
- attempts: 1,
- status: TaskStatus.Idle,
- runAt: new Date(),
- startedAt: null,
- retryAt: null,
- state: {},
- params: {},
- ownerId: null,
- });
const result = await rulesClient.create({ data });
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith(
'alert',
@@ -1274,6 +1279,7 @@ describe('create()', () => {
alertTypeId: '123',
consumer: 'bar',
name: 'abc',
+ legacyId: null,
params: { bar: true },
apiKey: null,
apiKeyOwner: null,
@@ -1283,7 +1289,7 @@ describe('create()', () => {
updatedAt: '2019-02-12T21:01:22.479Z',
enabled: true,
meta: {
- versionApiKeyLastmodified: 'v7.10.0',
+ versionApiKeyLastmodified: kibanaVersion,
},
schedule: { interval: '10s' },
throttle: '10m',
@@ -1386,19 +1392,6 @@ describe('create()', () => {
},
],
});
- taskManager.schedule.mockResolvedValueOnce({
- id: 'task-123',
- taskType: 'alerting:123',
- scheduledAt: new Date(),
- attempts: 1,
- status: TaskStatus.Idle,
- runAt: new Date(),
- startedAt: null,
- retryAt: null,
- state: {},
- params: {},
- ownerId: null,
- });
const result = await rulesClient.create({ data });
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith(
'alert',
@@ -1411,6 +1404,7 @@ describe('create()', () => {
params: { foo: true },
},
],
+ legacyId: null,
alertTypeId: '123',
consumer: 'bar',
name: 'abc',
@@ -1423,7 +1417,7 @@ describe('create()', () => {
updatedAt: '2019-02-12T21:01:22.479Z',
enabled: true,
meta: {
- versionApiKeyLastmodified: 'v7.10.0',
+ versionApiKeyLastmodified: kibanaVersion,
},
schedule: { interval: '10s' },
throttle: '10m',
@@ -1526,19 +1520,6 @@ describe('create()', () => {
},
],
});
- taskManager.schedule.mockResolvedValueOnce({
- id: 'task-123',
- taskType: 'alerting:123',
- scheduledAt: new Date(),
- attempts: 1,
- status: TaskStatus.Idle,
- runAt: new Date(),
- startedAt: null,
- retryAt: null,
- state: {},
- params: {},
- ownerId: null,
- });
const result = await rulesClient.create({ data });
expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith(
'alert',
@@ -1551,6 +1532,7 @@ describe('create()', () => {
params: { foo: true },
},
],
+ legacyId: null,
alertTypeId: '123',
consumer: 'bar',
name: 'abc',
@@ -1563,7 +1545,7 @@ describe('create()', () => {
updatedAt: '2019-02-12T21:01:22.479Z',
enabled: true,
meta: {
- versionApiKeyLastmodified: 'v7.10.0',
+ versionApiKeyLastmodified: kibanaVersion,
},
schedule: { interval: '10s' },
throttle: null,
@@ -1826,19 +1808,6 @@ describe('create()', () => {
},
],
});
- taskManager.schedule.mockResolvedValueOnce({
- id: 'task-123',
- taskType: 'alerting:123',
- scheduledAt: new Date(),
- attempts: 1,
- status: TaskStatus.Idle,
- runAt: new Date(),
- startedAt: null,
- retryAt: null,
- state: {},
- params: {},
- ownerId: null,
- });
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'alert',
@@ -1871,6 +1840,7 @@ describe('create()', () => {
alertTypeId: '123',
consumer: 'bar',
name: 'abc',
+ legacyId: null,
params: { bar: true },
apiKey: Buffer.from('123:abc').toString('base64'),
apiKeyOwner: 'elastic',
@@ -1880,7 +1850,7 @@ describe('create()', () => {
updatedAt: '2019-02-12T21:01:22.479Z',
enabled: true,
meta: {
- versionApiKeyLastmodified: 'v7.10.0',
+ versionApiKeyLastmodified: kibanaVersion,
},
schedule: { interval: '10s' },
throttle: null,
@@ -1937,19 +1907,6 @@ describe('create()', () => {
},
],
});
- taskManager.schedule.mockResolvedValueOnce({
- id: 'task-123',
- taskType: 'alerting:123',
- scheduledAt: new Date(),
- attempts: 1,
- status: TaskStatus.Idle,
- runAt: new Date(),
- startedAt: null,
- retryAt: null,
- state: {},
- params: {},
- ownerId: null,
- });
unsecuredSavedObjectsClient.create.mockResolvedValueOnce({
id: '1',
type: 'alert',
@@ -1979,6 +1936,7 @@ describe('create()', () => {
params: { foo: true },
},
],
+ legacyId: null,
alertTypeId: '123',
consumer: 'bar',
name: 'abc',
@@ -1991,7 +1949,7 @@ describe('create()', () => {
updatedAt: '2019-02-12T21:01:22.479Z',
enabled: false,
meta: {
- versionApiKeyLastmodified: 'v7.10.0',
+ versionApiKeyLastmodified: kibanaVersion,
},
schedule: { interval: '10s' },
throttle: null,
diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_instance_summary.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_instance_summary.test.ts
index d946c354872a7..f8414b08f191b 100644
--- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_instance_summary.test.ts
+++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_instance_summary.test.ts
@@ -72,6 +72,7 @@ const BaseAlertInstanceSummarySavedObject: SavedObject = {
tags: ['tag-1', 'tag-2'],
alertTypeId: '123',
consumer: 'alert-consumer',
+ legacyId: null,
schedule: { interval: `${AlertInstanceSummaryIntervalSeconds}s` },
actions: [],
params: {},
diff --git a/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts
new file mode 100644
index 0000000000000..63feb4ff3147a
--- /dev/null
+++ b/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts
@@ -0,0 +1,451 @@
+/*
+ * 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 { RulesClient, ConstructorOptions } from '../rules_client';
+import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks';
+import { taskManagerMock } from '../../../../task_manager/server/mocks';
+import { ruleTypeRegistryMock } from '../../rule_type_registry.mock';
+import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock';
+import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks';
+import { actionsAuthorizationMock } from '../../../../actions/server/mocks';
+import { AlertingAuthorization } from '../../authorization/alerting_authorization';
+import { ActionsAuthorization } from '../../../../actions/server';
+import { httpServerMock } from '../../../../../../src/core/server/mocks';
+import { auditServiceMock } from '../../../../security/server/audit/index.mock';
+import { getBeforeSetup, setGlobalDate } from './lib';
+import { RecoveredActionGroup } from '../../../common';
+
+const taskManager = taskManagerMock.createStart();
+const ruleTypeRegistry = ruleTypeRegistryMock.create();
+const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
+const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
+const authorization = alertingAuthorizationMock.create();
+const actionsAuthorization = actionsAuthorizationMock.create();
+const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
+
+const kibanaVersion = 'v7.10.0';
+const rulesClientParams: jest.Mocked = {
+ taskManager,
+ ruleTypeRegistry,
+ unsecuredSavedObjectsClient,
+ authorization: (authorization as unknown) as AlertingAuthorization,
+ actionsAuthorization: (actionsAuthorization as unknown) as ActionsAuthorization,
+ spaceId: 'default',
+ namespace: 'default',
+ getUserName: jest.fn(),
+ createAPIKey: jest.fn(),
+ logger: loggingSystemMock.create().get(),
+ encryptedSavedObjectsClient: encryptedSavedObjects,
+ getActionsClient: jest.fn(),
+ getEventLogClient: jest.fn(),
+ kibanaVersion,
+};
+
+beforeEach(() => {
+ getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry);
+ (auditLogger.log as jest.Mock).mockClear();
+});
+
+setGlobalDate();
+
+describe('resolve()', () => {
+ test('calls saved objects client with given params', async () => {
+ const rulesClient = new RulesClient(rulesClientParams);
+ unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ attributes: {
+ alertTypeId: '123',
+ schedule: { interval: '10s' },
+ params: {
+ bar: true,
+ },
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ actions: [
+ {
+ group: 'default',
+ actionRef: 'action_0',
+ params: {
+ foo: true,
+ },
+ },
+ ],
+ notifyWhen: 'onActiveAlert',
+ },
+ references: [
+ {
+ name: 'action_0',
+ type: 'action',
+ id: '1',
+ },
+ ],
+ },
+ outcome: 'aliasMatch',
+ alias_target_id: '2',
+ });
+ const result = await rulesClient.resolve({ id: '1' });
+ expect(result).toMatchInlineSnapshot(`
+ Object {
+ "actions": Array [
+ Object {
+ "group": "default",
+ "id": "1",
+ "params": Object {
+ "foo": true,
+ },
+ },
+ ],
+ "alertTypeId": "123",
+ "alias_target_id": "2",
+ "createdAt": 2019-02-12T21:01:22.479Z,
+ "id": "1",
+ "notifyWhen": "onActiveAlert",
+ "outcome": "aliasMatch",
+ "params": Object {
+ "bar": true,
+ },
+ "schedule": Object {
+ "interval": "10s",
+ },
+ "updatedAt": 2019-02-12T21:01:22.479Z,
+ }
+ `);
+ expect(unsecuredSavedObjectsClient.resolve).toHaveBeenCalledTimes(1);
+ expect(unsecuredSavedObjectsClient.resolve.mock.calls[0]).toMatchInlineSnapshot(`
+ Array [
+ "alert",
+ "1",
+ ]
+ `);
+ });
+
+ test('should call useSavedObjectReferences.injectReferences if defined for rule type', async () => {
+ const injectReferencesFn = jest.fn().mockReturnValue({
+ bar: true,
+ parameterThatIsSavedObjectId: '9',
+ });
+ ruleTypeRegistry.get.mockImplementation(() => ({
+ id: '123',
+ name: 'Test',
+ actionGroups: [{ id: 'default', name: 'Default' }],
+ recoveryActionGroup: RecoveredActionGroup,
+ defaultActionGroupId: 'default',
+ minimumLicenseRequired: 'basic',
+ isExportable: true,
+ async executor() {},
+ producer: 'alerts',
+ useSavedObjectReferences: {
+ extractReferences: jest.fn(),
+ injectReferences: injectReferencesFn,
+ },
+ }));
+ const rulesClient = new RulesClient(rulesClientParams);
+ unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ attributes: {
+ alertTypeId: '123',
+ schedule: { interval: '10s' },
+ params: {
+ bar: true,
+ parameterThatIsSavedObjectRef: 'soRef_0',
+ },
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ actions: [
+ {
+ group: 'default',
+ actionRef: 'action_0',
+ params: {
+ foo: true,
+ },
+ },
+ ],
+ notifyWhen: 'onActiveAlert',
+ },
+ references: [
+ {
+ name: 'action_0',
+ type: 'action',
+ id: '1',
+ },
+ {
+ name: 'param:soRef_0',
+ type: 'someSavedObjectType',
+ id: '9',
+ },
+ ],
+ },
+ outcome: 'aliasMatch',
+ alias_target_id: '2',
+ });
+ const result = await rulesClient.resolve({ id: '1' });
+
+ expect(injectReferencesFn).toHaveBeenCalledWith(
+ {
+ bar: true,
+ parameterThatIsSavedObjectRef: 'soRef_0',
+ },
+ [{ id: '9', name: 'soRef_0', type: 'someSavedObjectType' }]
+ );
+ expect(result).toMatchInlineSnapshot(`
+ Object {
+ "actions": Array [
+ Object {
+ "group": "default",
+ "id": "1",
+ "params": Object {
+ "foo": true,
+ },
+ },
+ ],
+ "alertTypeId": "123",
+ "alias_target_id": "2",
+ "createdAt": 2019-02-12T21:01:22.479Z,
+ "id": "1",
+ "notifyWhen": "onActiveAlert",
+ "outcome": "aliasMatch",
+ "params": Object {
+ "bar": true,
+ "parameterThatIsSavedObjectId": "9",
+ },
+ "schedule": Object {
+ "interval": "10s",
+ },
+ "updatedAt": 2019-02-12T21:01:22.479Z,
+ }
+ `);
+ });
+
+ test(`throws an error when references aren't found`, async () => {
+ const rulesClient = new RulesClient(rulesClientParams);
+ unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ attributes: {
+ alertTypeId: '123',
+ schedule: { interval: '10s' },
+ params: {
+ bar: true,
+ },
+ actions: [
+ {
+ group: 'default',
+ actionRef: 'action_0',
+ params: {
+ foo: true,
+ },
+ },
+ ],
+ },
+ references: [],
+ },
+ outcome: 'aliasMatch',
+ alias_target_id: '2',
+ });
+ await expect(rulesClient.resolve({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"Action reference \\"action_0\\" not found in alert id: 1"`
+ );
+ });
+
+ test('throws an error if useSavedObjectReferences.injectReferences throws an error', async () => {
+ const injectReferencesFn = jest.fn().mockImplementation(() => {
+ throw new Error('something went wrong!');
+ });
+ ruleTypeRegistry.get.mockImplementation(() => ({
+ id: '123',
+ name: 'Test',
+ actionGroups: [{ id: 'default', name: 'Default' }],
+ recoveryActionGroup: RecoveredActionGroup,
+ defaultActionGroupId: 'default',
+ minimumLicenseRequired: 'basic',
+ isExportable: true,
+ async executor() {},
+ producer: 'alerts',
+ useSavedObjectReferences: {
+ extractReferences: jest.fn(),
+ injectReferences: injectReferencesFn,
+ },
+ }));
+ const rulesClient = new RulesClient(rulesClientParams);
+ unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ attributes: {
+ alertTypeId: '123',
+ schedule: { interval: '10s' },
+ params: {
+ bar: true,
+ parameterThatIsSavedObjectRef: 'soRef_0',
+ },
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ actions: [
+ {
+ group: 'default',
+ actionRef: 'action_0',
+ params: {
+ foo: true,
+ },
+ },
+ ],
+ notifyWhen: 'onActiveAlert',
+ },
+ references: [
+ {
+ name: 'action_0',
+ type: 'action',
+ id: '1',
+ },
+ {
+ name: 'soRef_0',
+ type: 'someSavedObjectType',
+ id: '9',
+ },
+ ],
+ },
+ outcome: 'aliasMatch',
+ alias_target_id: '2',
+ });
+ await expect(rulesClient.resolve({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"Error injecting reference into rule params for rule id 1 - something went wrong!"`
+ );
+ });
+
+ describe('authorization', () => {
+ beforeEach(() => {
+ unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ attributes: {
+ alertTypeId: 'myType',
+ consumer: 'myApp',
+ schedule: { interval: '10s' },
+ params: {
+ bar: true,
+ },
+ actions: [
+ {
+ group: 'default',
+ actionRef: 'action_0',
+ params: {
+ foo: true,
+ },
+ },
+ ],
+ },
+ references: [
+ {
+ name: 'action_0',
+ type: 'action',
+ id: '1',
+ },
+ ],
+ },
+ outcome: 'aliasMatch',
+ alias_target_id: '2',
+ });
+ });
+
+ test('ensures user is authorised to resolve this type of rule under the consumer', async () => {
+ const rulesClient = new RulesClient(rulesClientParams);
+ await rulesClient.resolve({ id: '1' });
+
+ expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
+ entity: 'rule',
+ consumer: 'myApp',
+ operation: 'get',
+ ruleTypeId: 'myType',
+ });
+ });
+
+ test('throws when user is not authorised to get this type of alert', async () => {
+ const rulesClient = new RulesClient(rulesClientParams);
+ authorization.ensureAuthorized.mockRejectedValue(
+ new Error(`Unauthorized to get a "myType" alert for "myApp"`)
+ );
+
+ await expect(rulesClient.resolve({ id: '1' })).rejects.toMatchInlineSnapshot(
+ `[Error: Unauthorized to get a "myType" alert for "myApp"]`
+ );
+
+ expect(authorization.ensureAuthorized).toHaveBeenCalledWith({
+ entity: 'rule',
+ consumer: 'myApp',
+ operation: 'get',
+ ruleTypeId: 'myType',
+ });
+ });
+ });
+
+ describe('auditLogger', () => {
+ beforeEach(() => {
+ unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ attributes: {
+ alertTypeId: '123',
+ schedule: { interval: '10s' },
+ params: {
+ bar: true,
+ },
+ actions: [],
+ },
+ references: [],
+ },
+ outcome: 'aliasMatch',
+ alias_target_id: '2',
+ });
+ });
+
+ test('logs audit event when getting a rule', async () => {
+ const rulesClient = new RulesClient({ ...rulesClientParams, auditLogger });
+ await rulesClient.resolve({ id: '1' });
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'rule_resolve',
+ outcome: 'success',
+ }),
+ kibana: { saved_object: { id: '1', type: 'alert' } },
+ })
+ );
+ });
+
+ test('logs audit event when not authorised to get a rule', async () => {
+ const rulesClient = new RulesClient({ ...rulesClientParams, auditLogger });
+ authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized'));
+
+ await expect(rulesClient.resolve({ id: '1' })).rejects.toThrow();
+ expect(auditLogger.log).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ action: 'rule_resolve',
+ outcome: 'failure',
+ }),
+ kibana: {
+ saved_object: {
+ id: '1',
+ type: 'alert',
+ },
+ },
+ error: {
+ code: 'Error',
+ message: 'Unauthorized',
+ },
+ })
+ );
+ });
+ });
+});
diff --git a/x-pack/plugins/alerting/server/saved_objects/mappings.json b/x-pack/plugins/alerting/server/saved_objects/mappings.json
index 43292c6a54346..21d7a05f2a76d 100644
--- a/x-pack/plugins/alerting/server/saved_objects/mappings.json
+++ b/x-pack/plugins/alerting/server/saved_objects/mappings.json
@@ -28,6 +28,9 @@
"consumer": {
"type": "keyword"
},
+ "legacyId": {
+ "type": "keyword"
+ },
"actions": {
"type": "nested",
"properties": {
diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts
index b1460a5fe5cd8..c9a9d7c73a8a6 100644
--- a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts
+++ b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts
@@ -1416,6 +1416,20 @@ describe('successful migrations', () => {
});
});
});
+
+ describe('7.16.0', () => {
+ test('add legacyId field to alert - set to SavedObject id attribute', () => {
+ const migration716 = getMigrations(encryptedSavedObjectsSetup)['7.16.0'];
+ const alert = getMockData({}, true);
+ expect(migration716(alert, migrationContext)).toEqual({
+ ...alert,
+ attributes: {
+ ...alert.attributes,
+ legacyId: alert.id,
+ },
+ });
+ });
+ });
});
describe('handles errors during migrations', () => {
diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts
index 6823a9b9b20da..d53943991b215 100644
--- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts
+++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts
@@ -99,6 +99,12 @@ export function getMigrations(
pipeMigrations(addExceptionListsToReferences)
);
+ const migrateLegacyIds716 = createEsoMigration(
+ encryptedSavedObjects,
+ (doc): doc is SavedObjectUnsanitizedDoc => true,
+ pipeMigrations(setLegacyId)
+ );
+
return {
'7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'),
'7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'),
@@ -106,6 +112,7 @@ export function getMigrations(
'7.13.0': executeMigrationWithErrorHandling(migrationSecurityRules713, '7.13.0'),
'7.14.1': executeMigrationWithErrorHandling(migrationSecurityRules714, '7.14.1'),
'7.15.0': executeMigrationWithErrorHandling(migrationSecurityRules715, '7.15.0'),
+ '7.16.0': executeMigrationWithErrorHandling(migrateLegacyIds716, '7.16.0'),
};
}
@@ -567,6 +574,19 @@ function removeMalformedExceptionsList(
}
}
+function setLegacyId(
+ doc: SavedObjectUnsanitizedDoc
+): SavedObjectUnsanitizedDoc {
+ const { id } = doc;
+ return {
+ ...doc,
+ attributes: {
+ ...doc.attributes,
+ legacyId: id,
+ },
+ };
+}
+
function pipeMigrations(...migrations: AlertMigration[]): AlertMigration {
return (doc: SavedObjectUnsanitizedDoc) =>
migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc);
diff --git a/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.test.ts b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.test.ts
index 5997df2895761..8236c4455478c 100644
--- a/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.test.ts
+++ b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.test.ts
@@ -35,6 +35,7 @@ describe('transform rule for export', () => {
apiKey: '4tndskbuhewotw4klrhgjewrt9u',
apiKeyOwner: 'me',
throttle: null,
+ legacyId: '1',
notifyWhen: 'onActionGroupChange',
muteAll: false,
mutedInstanceIds: [],
@@ -66,6 +67,7 @@ describe('transform rule for export', () => {
apiKey: null,
apiKeyOwner: null,
throttle: null,
+ legacyId: '2',
notifyWhen: 'onActionGroupChange',
muteAll: false,
mutedInstanceIds: [],
@@ -90,6 +92,7 @@ describe('transform rule for export', () => {
apiKey: null,
apiKeyOwner: null,
scheduledTaskId: null,
+ legacyId: null,
executionStatus: {
status: 'pending',
lastExecutionDate: '2020-08-20T19:23:38Z',
diff --git a/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.ts b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.ts
index 707bd84e948bf..97fd226b49e8e 100644
--- a/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.ts
+++ b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.ts
@@ -22,6 +22,7 @@ function transformRuleForExport(
...rule,
attributes: {
...rule.attributes,
+ legacyId: null,
enabled: false,
apiKey: null,
apiKeyOwner: null,
diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts
index 8b8fce7a1bf62..67565271fedc8 100644
--- a/x-pack/plugins/alerting/server/types.ts
+++ b/x-pack/plugins/alerting/server/types.ts
@@ -198,6 +198,7 @@ export interface RawAlert extends SavedObjectAttributes {
tags: string[];
alertTypeId: string;
consumer: string;
+ legacyId: string | null;
schedule: SavedObjectAttributes;
actions: RawAlertAction[];
params: SavedObjectAttributes;
diff --git a/x-pack/plugins/apm/ftr_e2e/config.ts b/x-pack/plugins/apm/ftr_e2e/config.ts
index fc8fb2d5fe292..36acc4a93ecbb 100644
--- a/x-pack/plugins/apm/ftr_e2e/config.ts
+++ b/x-pack/plugins/apm/ftr_e2e/config.ts
@@ -33,6 +33,7 @@ async function config({ readConfigFile }: FtrConfigProviderContext) {
...xpackFunctionalTestsConfig.get('kbnTestServer'),
serverArgs: [
...xpackFunctionalTestsConfig.get('kbnTestServer.serverArgs'),
+ '--home.disableWelcomeScreen=true',
'--csp.strict=false',
// define custom kibana server args here
`--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`,
diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/home.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/home.spec.ts
index e26261035c084..9d4c773422cdc 100644
--- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/home.spec.ts
+++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/home.spec.ts
@@ -7,7 +7,6 @@
import url from 'url';
import archives_metadata from '../../fixtures/es_archiver/archives_metadata';
-import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver';
const { start, end } = archives_metadata['apm_8.0.0'];
@@ -28,15 +27,10 @@ const apisToIntercept = [
];
describe('Home page', () => {
- before(() => {
- esArchiverLoad('apm_8.0.0');
- });
- after(() => {
- esArchiverUnload('apm_8.0.0');
- });
beforeEach(() => {
cy.loginAsReadOnlyUser();
});
+
it('Redirects to service page with rangeFrom and rangeTo added to the URL', () => {
cy.visit('/app/apm');
diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/header_filters.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/header_filters.spec.ts
index f124b3818c193..7e8d2d02b1f82 100644
--- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/header_filters.spec.ts
+++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/header_filters.spec.ts
@@ -6,7 +6,6 @@
*/
import url from 'url';
import archives_metadata from '../../../fixtures/es_archiver/archives_metadata';
-import { esArchiverLoad, esArchiverUnload } from '../../../tasks/es_archiver';
const { start, end } = archives_metadata['apm_8.0.0'];
@@ -59,15 +58,10 @@ const apisToIntercept = [
];
describe('Service overview - header filters', () => {
- before(() => {
- esArchiverLoad('apm_8.0.0');
- });
- after(() => {
- esArchiverUnload('apm_8.0.0');
- });
beforeEach(() => {
cy.loginAsReadOnlyUser();
});
+
describe('Filtering by transaction type', () => {
it('changes url when selecting different value', () => {
cy.visit(serviceOverviewHref);
diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/instances_table.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/instances_table.spec.ts
index 9428f9b9e6bb6..d972602d9e496 100644
--- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/instances_table.spec.ts
+++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/instances_table.spec.ts
@@ -7,7 +7,6 @@
import url from 'url';
import archives_metadata from '../../../fixtures/es_archiver/archives_metadata';
-import { esArchiverLoad, esArchiverUnload } from '../../../tasks/es_archiver';
const { start, end } = archives_metadata['apm_8.0.0'];
@@ -43,25 +42,21 @@ describe('Instances table', () => {
beforeEach(() => {
cy.loginAsReadOnlyUser();
});
- describe('when data is not loaded', () => {
- it('shows empty message', () => {
- cy.visit(serviceOverviewHref);
- cy.contains('opbeans-java');
- cy.get('[data-test-subj="serviceInstancesTableContainer"]').contains(
- 'No items found'
- );
- });
- });
+
+ // describe('when data is not loaded', () => {
+ // it('shows empty message', () => {
+ // cy.visit(serviceOverviewHref);
+ // cy.contains('opbeans-java');
+ // cy.get('[data-test-subj="serviceInstancesTableContainer"]').contains(
+ // 'No items found'
+ // );
+ // });
+ // });
describe('when data is loaded', () => {
- before(() => {
- esArchiverLoad('apm_8.0.0');
- });
- after(() => {
- esArchiverUnload('apm_8.0.0');
- });
const serviceNodeName =
'31651f3c624b81c55dd4633df0b5b9f9ab06b151121b0404ae796632cd1f87ad';
+
it('has data in the table', () => {
cy.visit(serviceOverviewHref);
cy.contains('opbeans-java');
diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts
index 7c5d5988c9bf6..e3670d77c1143 100644
--- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts
+++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts
@@ -7,7 +7,6 @@
import url from 'url';
import archives_metadata from '../../../fixtures/es_archiver/archives_metadata';
-import { esArchiverLoad, esArchiverUnload } from '../../../tasks/es_archiver';
const { start, end } = archives_metadata['apm_8.0.0'];
@@ -18,15 +17,10 @@ const baseUrl = url.format({
});
describe('Service Overview', () => {
- before(() => {
- esArchiverLoad('apm_8.0.0');
- });
- after(() => {
- esArchiverUnload('apm_8.0.0');
- });
beforeEach(() => {
cy.loginAsReadOnlyUser();
});
+
it('persists transaction type selected when clicking on Transactions tab', () => {
cy.visit(baseUrl);
cy.get('[data-test-subj="headerFilterTransactionType"]').should(
diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/time_comparison.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/time_comparison.spec.ts
index de05cc3abb927..5b6cb08e21ebb 100644
--- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/time_comparison.spec.ts
+++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/time_comparison.spec.ts
@@ -7,7 +7,6 @@
import url from 'url';
import moment from 'moment';
import archives_metadata from '../../../fixtures/es_archiver/archives_metadata';
-import { esArchiverLoad, esArchiverUnload } from '../../../tasks/es_archiver';
const { start, end } = archives_metadata['apm_8.0.0'];
@@ -47,12 +46,6 @@ const apisToIntercept = [
];
describe('Service overview: Time Comparison', () => {
- before(() => {
- esArchiverLoad('apm_8.0.0');
- });
- after(() => {
- esArchiverUnload('apm_8.0.0');
- });
beforeEach(() => {
cy.loginAsReadOnlyUser();
});
diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transactions_overview/transactions_overview.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transactions_overview/transactions_overview.spec.ts
index eaa0ee9e4d65a..9180e6371fda7 100644
--- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transactions_overview/transactions_overview.spec.ts
+++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transactions_overview/transactions_overview.spec.ts
@@ -7,7 +7,6 @@
import url from 'url';
import archives_metadata from '../../../fixtures/es_archiver/archives_metadata';
-import { esArchiverLoad, esArchiverUnload } from '../../../tasks/es_archiver';
const { start, end } = archives_metadata['apm_8.0.0'];
@@ -17,15 +16,10 @@ const serviceOverviewHref = url.format({
});
describe('Transactions Overview', () => {
- before(() => {
- esArchiverLoad('apm_8.0.0');
- });
- after(() => {
- esArchiverUnload('apm_8.0.0');
- });
beforeEach(() => {
cy.loginAsReadOnlyUser();
});
+
it('persists transaction type selected when navigating to Overview tab', () => {
cy.visit(serviceOverviewHref);
cy.get('[data-test-subj="headerFilterTransactionType"]').should(
diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/support/index.ts b/x-pack/plugins/apm/ftr_e2e/cypress/support/index.ts
index 3dbe36647a851..e2025c01d4c7a 100644
--- a/x-pack/plugins/apm/ftr_e2e/cypress/support/index.ts
+++ b/x-pack/plugins/apm/ftr_e2e/cypress/support/index.ts
@@ -5,4 +5,12 @@
* 2.0.
*/
+Cypress.on('uncaught:exception', (err, runnable) => {
+ // @see https://stackoverflow.com/a/50387233/434980
+ // ResizeObserver error can be safely ignored
+ if (err.message.includes('ResizeObserver loop limit exceeded')) {
+ return false;
+ }
+});
+
import './commands';
diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts b/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts
index 3912b60dd56ed..5e4dd9f8657ff 100644
--- a/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts
+++ b/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts
@@ -6,31 +6,32 @@
*/
import Path from 'path';
+import { execSync } from 'child_process';
const ES_ARCHIVE_DIR = './cypress/fixtures/es_archiver';
-// Otherwise cy.exec would inject NODE_TLS_REJECT_UNAUTHORIZED=0 and node would abort if used over https
+// Otherwise execSync would inject NODE_TLS_REJECT_UNAUTHORIZED=0 and node would abort if used over https
const NODE_TLS_REJECT_UNAUTHORIZED = '1';
export const esArchiverLoad = (folder: string) => {
const path = Path.join(ES_ARCHIVE_DIR, folder);
- cy.exec(
+ execSync(
`node ../../../../scripts/es_archiver load "${path}" --config ../../../test/functional/config.js`,
- { env: { NODE_TLS_REJECT_UNAUTHORIZED } }
+ { env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED } }
);
};
export const esArchiverUnload = (folder: string) => {
const path = Path.join(ES_ARCHIVE_DIR, folder);
- cy.exec(
+ execSync(
`node ../../../../scripts/es_archiver unload "${path}" --config ../../../test/functional/config.js`,
- { env: { NODE_TLS_REJECT_UNAUTHORIZED } }
+ { env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED } }
);
};
export const esArchiverResetKibana = () => {
- cy.exec(
+ execSync(
`node ../../../../scripts/es_archiver empty-kibana-index --config ../../../test/functional/config.js`,
- { env: { NODE_TLS_REJECT_UNAUTHORIZED }, failOnNonZeroExit: false }
+ { env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED } }
);
};
diff --git a/x-pack/plugins/apm/ftr_e2e/cypress_open.ts b/x-pack/plugins/apm/ftr_e2e/cypress_open.ts
index ec52f387a8b98..3f7758b40b90d 100644
--- a/x-pack/plugins/apm/ftr_e2e/cypress_open.ts
+++ b/x-pack/plugins/apm/ftr_e2e/cypress_open.ts
@@ -9,9 +9,9 @@ import { FtrConfigProviderContext } from '@kbn/test';
import { cypressOpenTests } from './cypress_start';
async function openE2ETests({ readConfigFile }: FtrConfigProviderContext) {
- const cypressConfig = await readConfigFile(require.resolve('./config.ts'));
+ const kibanaConfig = await readConfigFile(require.resolve('./config.ts'));
return {
- ...cypressConfig.getAll(),
+ ...kibanaConfig.getAll(),
testRunner: cypressOpenTests,
};
}
diff --git a/x-pack/plugins/apm/ftr_e2e/cypress_run.ts b/x-pack/plugins/apm/ftr_e2e/cypress_run.ts
index eb319f4b30835..16f93b39910f3 100644
--- a/x-pack/plugins/apm/ftr_e2e/cypress_run.ts
+++ b/x-pack/plugins/apm/ftr_e2e/cypress_run.ts
@@ -8,13 +8,13 @@ import { argv } from 'yargs';
import { FtrConfigProviderContext } from '@kbn/test';
import { cypressRunTests } from './cypress_start';
-const spec = argv.grep as string;
+const specArg = argv.spec as string | undefined;
async function runE2ETests({ readConfigFile }: FtrConfigProviderContext) {
- const cypressConfig = await readConfigFile(require.resolve('./config.ts'));
+ const kibanaConfig = await readConfigFile(require.resolve('./config.ts'));
return {
- ...cypressConfig.getAll(),
- testRunner: cypressRunTests(spec),
+ ...kibanaConfig.getAll(),
+ testRunner: cypressRunTests(specArg),
};
}
diff --git a/x-pack/plugins/apm/ftr_e2e/cypress_start.ts b/x-pack/plugins/apm/ftr_e2e/cypress_start.ts
index 7468a4473b311..67617f5a21fd8 100644
--- a/x-pack/plugins/apm/ftr_e2e/cypress_start.ts
+++ b/x-pack/plugins/apm/ftr_e2e/cypress_start.ts
@@ -9,7 +9,8 @@ import Url from 'url';
import cypress from 'cypress';
import { FtrProviderContext } from './ftr_provider_context';
import archives_metadata from './cypress/fixtures/es_archiver/archives_metadata';
-import { createKibanaUserRole } from '../scripts/kibana-security/create_kibana_user_role';
+import { createApmUsersAndRoles } from '../scripts/create-apm-users-and-roles/create_apm_users_and_roles';
+import { esArchiverLoad, esArchiverUnload } from './cypress/tasks/es_archiver';
export function cypressRunTests(spec?: string) {
return async ({ getService }: FtrProviderContext) => {
@@ -47,7 +48,7 @@ async function cypressStart(
});
// Creates APM users
- await createKibanaUserRole({
+ await createApmUsersAndRoles({
elasticsearch: {
username: config.get('servers.elasticsearch.username'),
password: config.get('servers.elasticsearch.password'),
@@ -58,8 +59,10 @@ async function cypressStart(
},
});
- return cypressExecution({
- ...(spec !== 'undefined' ? { spec } : {}),
+ await esArchiverLoad('apm_8.0.0');
+
+ const res = await cypressExecution({
+ ...(spec !== undefined ? { spec } : {}),
config: { baseUrl: kibanaUrl },
env: {
START_DATE: start,
@@ -67,4 +70,8 @@ async function cypressStart(
KIBANA_URL: kibanaUrl,
},
});
+
+ await esArchiverUnload('apm_8.0.0');
+
+ return res;
}
diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap
index 2b85d6bb3c229..6ce38b4470093 100644
--- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap
+++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap
@@ -255,7 +255,7 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = `
- No errors were found
+ No errors found