From 550d44ea2da437be7f1b3ab08f7125481b04737c Mon Sep 17 00:00:00 2001 From: spalger Date: Thu, 30 Jul 2020 09:26:32 -0700 Subject: [PATCH 01/11] [i18n] remove unused translation keys --- x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - 2 files changed, 2 deletions(-) diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 69a8449c4dc708..ff8c3186d5cf89 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5008,7 +5008,6 @@ "xpack.apm.settings.anomaly_detection.legacy_jobs.body": "以前の統合のレガシー機械学習ジョブが見つかりました。これは、APMアプリでは使用されていません。", "xpack.apm.settings.anomaly_detection.legacy_jobs.button": "ジョブの確認", "xpack.apm.settings.anomaly_detection.legacy_jobs.title": "レガシーMLジョブはAPMアプリで使用されていません。", - "xpack.apm.settings.anomaly_detection.license.text": "異常検知を使用するには、Elastic Platinumライセンスのサブスクリプションが必要です。このライセンスがあれば、機械学習を活用して、サービスを監視できます。", "xpack.apm.settings.anomalyDetection": "異常検知", "xpack.apm.settings.anomalyDetection.addEnvironments.cancelButtonText": "キャンセル", "xpack.apm.settings.anomalyDetection.addEnvironments.createJobsButtonText": "ジョブの作成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 898897548b7fc5..bcf2f6707e7bfc 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5009,7 +5009,6 @@ "xpack.apm.settings.anomaly_detection.legacy_jobs.body": "我们在以前的集成中发现 APM 应用中不再使用的旧版 Machine Learning 作业", "xpack.apm.settings.anomaly_detection.legacy_jobs.button": "复查作业", "xpack.apm.settings.anomaly_detection.legacy_jobs.title": "旧版 ML 作业不再用于 APM 应用", - "xpack.apm.settings.anomaly_detection.license.text": "要使用异常检测,必须订阅 Elastic 白金级许可。使用该许可,您将能够借助 Machine Learning 监测服务。", "xpack.apm.settings.anomalyDetection": "异常检测", "xpack.apm.settings.anomalyDetection.addEnvironments.cancelButtonText": "取消", "xpack.apm.settings.anomalyDetection.addEnvironments.createJobsButtonText": "创建作业", From c09216b6698eee0189e096dd67085bbe9ff12e4b Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Thu, 30 Jul 2020 10:30:57 -0600 Subject: [PATCH 02/11] [Maps] fix application state filters transfer from other kibana application to maps application (#73516) * [Maps] fix application state filters transfer from other kibana application to maps application * clean up comment Co-authored-by: Elastic Machine --- .../maps/public/routing/routes/maps_app/maps_app_view.js | 8 -------- .../plugins/maps/public/routing/state_syncing/app_sync.js | 4 ++++ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js index 7578d2fd3d3eab..d945aa9623b212 100644 --- a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js @@ -91,14 +91,6 @@ export class MapsAppView extends React.Component { this._globalSyncChangeMonitorSubscription.unsubscribe(); } - // Clean up app state filters - const { filterManager } = getData().query; - filterManager.filters.forEach((filter) => { - if (filter.$state.store === esFilters.FilterStateStore.APP_STATE) { - filterManager.removeFilter(filter); - } - }); - getCoreChrome().setBreadcrumbs([]); } diff --git a/x-pack/plugins/maps/public/routing/state_syncing/app_sync.js b/x-pack/plugins/maps/public/routing/state_syncing/app_sync.js index 8dd643607ba9c5..60e8dc9cd574cb 100644 --- a/x-pack/plugins/maps/public/routing/state_syncing/app_sync.js +++ b/x-pack/plugins/maps/public/routing/state_syncing/app_sync.js @@ -15,6 +15,10 @@ export function startAppStateSyncing(appStateManager) { // sync app filters with app state container from data.query to state container const { query } = getData(); + // Filter manager state persists across applications + // clear app state filters to prevent application filters from other applications being transfered to maps + query.filterManager.setAppFilters([]); + const stateContainer = { get: () => ({ query: appStateManager.getQuery(), From e8cc2ff72f967a048d64c1f5072df8b1a4f2dae9 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Thu, 30 Jul 2020 13:02:54 -0400 Subject: [PATCH 03/11] [SECURITY_SOLUTION][ENDPOINT] Fix Endpoint Hosts crashing periodically when switching between policy responses from linux and windows (#73809) * Fix use of hooks in policy response * Additional fixes to use of `useMemo` * Added error boundary to management pages --- .../components/management_page_view.tsx | 7 ++- .../view/details/policy_response.tsx | 12 ++---- .../view/policy_forms/events/checkbox.tsx | 3 +- .../view/policy_forms/protections/malware.tsx | 3 +- .../pages/policy/view/policy_list.tsx | 43 ++++++++----------- 5 files changed, 31 insertions(+), 37 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx b/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx index aa562b9a202017..54d9131209d0d8 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx @@ -5,10 +5,15 @@ */ import React, { memo } from 'react'; +import { EuiErrorBoundary } from '@elastic/eui'; import { PageView, PageViewProps } from '../../common/components/endpoint/page_view'; export const ManagementPageView = memo>((options) => { - return ; + return ( + + + + ); }); ManagementPageView.displayName = 'ManagementPageView'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response.tsx index 4cdfaad69eb726..3a1dd180405e04 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response.tsx @@ -140,20 +140,16 @@ export const PolicyResponse = memo( responseActions: Immutable; responseAttentionCount: Map; }) => { + const generateId = useMemo(() => htmlIdGenerator(), []); + return ( <> {Object.entries(responseConfig).map(([key, val]) => { const attentionCount = responseAttentionCount.get(key); return ( htmlIdGenerator()(), []) - } - key={ - /* eslint-disable-next-line react-hooks/rules-of-hooks */ - useMemo(() => htmlIdGenerator()(), []) - } + id={generateId(`id_${key}`)} + key={generateId(`key_${key}`)} data-test-subj="hostDetailsPolicyResponseConfigAccordion" buttonContent={ diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/checkbox.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/checkbox.tsx index 9ceade5d0264c4..76077831c670b4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/checkbox.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/checkbox.tsx @@ -27,6 +27,7 @@ export const EventsCheckbox = React.memo(function ({ const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); const selected = getter(policyDetailsConfig); const dispatch = useDispatch<(action: PolicyDetailsAction) => void>(); + const checkboxId = useMemo(() => htmlIdGenerator()(), []); const handleCheckboxChange = useCallback( (event: React.ChangeEvent) => { @@ -42,7 +43,7 @@ export const EventsCheckbox = React.memo(function ({ return ( htmlIdGenerator()(), [])} + id={checkboxId} label={name} checked={selected} onChange={handleCheckboxChange} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx index 84d4bf5355cd98..1698f5bc3fd0c2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -41,6 +41,7 @@ const protection = 'malware'; const ProtectionRadio = React.memo(({ id, label }: { id: ProtectionModes; label: string }) => { const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); const dispatch = useDispatch(); + const radioButtonId = useMemo(() => htmlIdGenerator()(), []); // currently just taking windows.malware, but both windows.malware and mac.malware should be the same value const selected = policyDetailsConfig && policyDetailsConfig.windows.malware.mode; @@ -66,7 +67,7 @@ const ProtectionRadio = React.memo(({ id, label }: { id: ProtectionModes; label: htmlIdGenerator()(), [])} + id={radioButtonId} checked={selected === id} onChange={handleRadioChange} disabled={selected === ProtectionModes.off} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx index 246dbeb39886fb..39b77d259add1b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -377,6 +377,22 @@ export const PolicyList = React.memo(() => { [services.application, handleDeleteOnClick, formatUrl, search] ); + const bodyContent = useMemo(() => { + return policyItems && policyItems.length > 0 ? ( + + ) : ( + + ); + }, [policyItems, loading, columns, handleCreatePolicyClick, handleTableChange, paginationSetup]); + return ( <> {showDelete && ( @@ -449,32 +465,7 @@ export const PolicyList = React.memo(() => { )} - {useMemo(() => { - return ( - <> - {policyItems && policyItems.length > 0 ? ( - - ) : ( - - )} - - ); - }, [ - policyItems, - loading, - columns, - handleCreatePolicyClick, - handleTableChange, - paginationSetup, - ])} + {bodyContent} From 585d58c202e84945877f6138769bc0414c23ab09 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Thu, 30 Jul 2020 20:12:37 +0300 Subject: [PATCH 04/11] [KP] Expose new es client (#73651) * mark legacy ES client types as deprecated * expose es client to plugins and update mocks * ElasticSearchClientMock --> ElasticsearchClientMock * expose es client mocks * expose es client via RequestHandlerContext * convert test/plugin_functional/config into ts * convert top_nav test into ts * add an integration test for the es client * update comments to refer to the new es client * fix import paths. do not use extensions temp * update docs * fix other refs * add test for a custom client * fix context * add test for scoped client * update docs --- ...lugin-core-server.assistanceapiresponse.md | 3 + ...in-core-server.assistantapiclientparams.md | 3 + ...-core-server.deprecationapiclientparams.md | 3 + ...ugin-core-server.deprecationapiresponse.md | 3 + ...bana-plugin-core-server.deprecationinfo.md | 3 + ...n-core-server.elasticsearchclientconfig.md | 18 +++++ ...server.elasticsearchservicestart.client.md | 22 +++++++ ....elasticsearchservicestart.createclient.md | 23 +++++++ ...n-core-server.elasticsearchservicestart.md | 2 + ...ervicesetup.registerroutehandlercontext.md | 2 +- ...re-server.iclusterclient.asinternaluser.md | 13 ++++ ...gin-core-server.iclusterclient.asscoped.md | 13 ++++ ...ibana-plugin-core-server.iclusterclient.md | 21 ++++++ ...-core-server.icustomclusterclient.close.md | 13 ++++ ...plugin-core-server.icustomclusterclient.md | 20 ++++++ ...plugin-core-server.ilegacyclusterclient.md | 5 ++ ...-core-server.ilegacycustomclusterclient.md | 5 ++ ...-core-server.ilegacyscopedclusterclient.md | 5 ++ ...ore-server.indexsettingsdeprecationinfo.md | 3 + ...rver.iscopedclusterclient.ascurrentuser.md | 13 ++++ ...ver.iscopedclusterclient.asinternaluser.md | 13 ++++ ...plugin-core-server.iscopedclusterclient.md | 21 ++++++ ...bana-plugin-core-server.legacyapicaller.md | 3 + ...plugin-core-server.legacycallapioptions.md | 4 ++ ...-plugin-core-server.legacyclusterclient.md | 6 ++ ...-server.legacyelasticsearchclientconfig.md | 3 + ...in-core-server.legacyelasticsearcherror.md | 1 + ...n-core-server.legacyscopedclusterclient.md | 6 ++ .../core/server/kibana-plugin-core-server.md | 12 ++-- ...erver.migration_assistance_index_action.md | 3 + ...core-server.migration_deprecation_level.md | 3 + ...-core-server.requesthandlercontext.core.md | 1 + ...lugin-core-server.requesthandlercontext.md | 4 +- scripts/functional_tests.js | 2 +- src/core/server/elasticsearch/client/mocks.ts | 15 ++--- .../client/retry_call_cluster.test.ts | 8 +-- .../client/scoped_cluster_client.test.ts | 8 +-- .../client/scoped_cluster_client.ts | 2 +- .../elasticsearch_service.mock.ts | 30 ++++----- .../server/elasticsearch/legacy/api_types.ts | 46 ++++++++++--- .../elasticsearch/legacy/cluster_client.ts | 4 +- .../legacy/elasticsearch_client_config.ts | 1 + .../server/elasticsearch/legacy/errors.ts | 5 +- .../legacy/scoped_cluster_client.ts | 2 + src/core/server/elasticsearch/types.ts | 64 +++++++++--------- src/core/server/http/types.ts | 2 +- src/core/server/index.ts | 11 +++- src/core/server/mocks.ts | 1 + src/core/server/plugins/plugin_context.ts | 2 + .../migrations/core/elastic_index.test.ts | 4 +- .../migrations/core/index_migrator.test.ts | 6 +- .../core/migration_es_client.test.ts | 4 +- .../migrations/kibana/kibana_migrator.test.ts | 4 +- .../service/lib/repository.test.js | 2 +- .../service/lib/repository_es_client.test.ts | 4 +- src/core/server/server.api.md | 66 +++++++++++++------ src/core/server/server.ts | 1 + src/plugins/data/server/server.api.md | 1 + tasks/config/run.js | 2 +- test/api_integration/services/index.ts | 1 - test/plugin_functional/README.md | 6 +- .../{config.js => config.ts} | 6 +- .../elasticsearch_client_plugin/kibana.json | 7 ++ .../elasticsearch_client_plugin/package.json | 15 +++++ .../server/index.ts} | 10 +-- .../server/plugin.ts | 53 +++++++++++++++ .../elasticsearch_client_plugin/tsconfig.json | 12 ++++ test/plugin_functional/services/index.ts | 11 +--- .../core_plugins/elasticsearch_client.ts | 38 +++++++++++ .../test_suites/core_plugins/index.ts | 1 + .../core_plugins/{top_nav.js => top_nav.ts} | 4 +- x-pack/test/plugin_api_integration/config.ts | 1 + .../plugins/elasticsearch_client/kibana.json | 7 ++ .../plugins/elasticsearch_client/package.json | 14 ++++ .../elasticsearch_client/server/index.ts | 9 +++ .../elasticsearch_client/server/plugin.ts | 36 ++++++++++ .../platform/elasticsearch_client.ts | 26 ++++++++ .../test_suites/platform/index.ts | 14 ++++ 78 files changed, 695 insertions(+), 146 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.elasticsearchclientconfig.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.client.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.createclient.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iclusterclient.asinternaluser.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iclusterclient.asscoped.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iclusterclient.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.icustomclusterclient.close.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.icustomclusterclient.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.ascurrentuser.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.asinternaluser.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md rename test/plugin_functional/{config.js => config.ts} (94%) create mode 100644 test/plugin_functional/plugins/elasticsearch_client_plugin/kibana.json create mode 100644 test/plugin_functional/plugins/elasticsearch_client_plugin/package.json rename test/plugin_functional/{services/supertest.ts => plugins/elasticsearch_client_plugin/server/index.ts} (66%) create mode 100644 test/plugin_functional/plugins/elasticsearch_client_plugin/server/plugin.ts create mode 100644 test/plugin_functional/plugins/elasticsearch_client_plugin/tsconfig.json create mode 100644 test/plugin_functional/test_suites/core_plugins/elasticsearch_client.ts rename test/plugin_functional/test_suites/core_plugins/{top_nav.js => top_nav.ts} (88%) create mode 100644 x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json create mode 100644 x-pack/test/plugin_api_integration/plugins/elasticsearch_client/package.json create mode 100644 x-pack/test/plugin_api_integration/plugins/elasticsearch_client/server/index.ts create mode 100644 x-pack/test/plugin_api_integration/plugins/elasticsearch_client/server/plugin.ts create mode 100644 x-pack/test/plugin_api_integration/test_suites/platform/elasticsearch_client.ts create mode 100644 x-pack/test/plugin_api_integration/test_suites/platform/index.ts diff --git a/docs/development/core/server/kibana-plugin-core-server.assistanceapiresponse.md b/docs/development/core/server/kibana-plugin-core-server.assistanceapiresponse.md index 9bc24f4d1d366c..4778c98493b5bd 100644 --- a/docs/development/core/server/kibana-plugin-core-server.assistanceapiresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.assistanceapiresponse.md @@ -4,6 +4,9 @@ ## AssistanceAPIResponse interface +> Warning: This API is now obsolete. +> +> Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.assistantapiclientparams.md b/docs/development/core/server/kibana-plugin-core-server.assistantapiclientparams.md index 8be7a9edde3632..6d3f8df2fa5182 100644 --- a/docs/development/core/server/kibana-plugin-core-server.assistantapiclientparams.md +++ b/docs/development/core/server/kibana-plugin-core-server.assistantapiclientparams.md @@ -4,6 +4,9 @@ ## AssistantAPIClientParams interface +> Warning: This API is now obsolete. +> +> Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.deprecationapiclientparams.md b/docs/development/core/server/kibana-plugin-core-server.deprecationapiclientparams.md index e545cf42d3c26f..ed64d61e75fabc 100644 --- a/docs/development/core/server/kibana-plugin-core-server.deprecationapiclientparams.md +++ b/docs/development/core/server/kibana-plugin-core-server.deprecationapiclientparams.md @@ -4,6 +4,9 @@ ## DeprecationAPIClientParams interface +> Warning: This API is now obsolete. +> +> Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.deprecationapiresponse.md b/docs/development/core/server/kibana-plugin-core-server.deprecationapiresponse.md index 1f6e1f9988fc26..1d837d9b4705d6 100644 --- a/docs/development/core/server/kibana-plugin-core-server.deprecationapiresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.deprecationapiresponse.md @@ -4,6 +4,9 @@ ## DeprecationAPIResponse interface +> Warning: This API is now obsolete. +> +> Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.deprecationinfo.md b/docs/development/core/server/kibana-plugin-core-server.deprecationinfo.md index bd343f5bc74764..8eeb5ef638a829 100644 --- a/docs/development/core/server/kibana-plugin-core-server.deprecationinfo.md +++ b/docs/development/core/server/kibana-plugin-core-server.deprecationinfo.md @@ -4,6 +4,9 @@ ## DeprecationInfo interface +> Warning: This API is now obsolete. +> +> Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchclientconfig.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchclientconfig.md new file mode 100644 index 00000000000000..1ba359e81b9c62 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchclientconfig.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ElasticsearchClientConfig](./kibana-plugin-core-server.elasticsearchclientconfig.md) + +## ElasticsearchClientConfig type + +Configuration options to be used to create a [cluster client](./kibana-plugin-core-server.iclusterclient.md) using the [createClient API](./kibana-plugin-core-server.elasticsearchservicestart.createclient.md) + +Signature: + +```typescript +export declare type ElasticsearchClientConfig = Pick & { + pingTimeout?: ElasticsearchConfig['pingTimeout'] | ClientOptions['pingTimeout']; + requestTimeout?: ElasticsearchConfig['requestTimeout'] | ClientOptions['requestTimeout']; + ssl?: Partial; + keepAlive?: boolean; +}; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.client.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.client.md new file mode 100644 index 00000000000000..591f126c423e3c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.client.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ElasticsearchServiceStart](./kibana-plugin-core-server.elasticsearchservicestart.md) > [client](./kibana-plugin-core-server.elasticsearchservicestart.client.md) + +## ElasticsearchServiceStart.client property + +A pre-configured [Elasticsearch client](./kibana-plugin-core-server.iclusterclient.md) + +Signature: + +```typescript +readonly client: IClusterClient; +``` + +## Example + + +```js +const client = core.elasticsearch.client; + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.createclient.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.createclient.md new file mode 100644 index 00000000000000..d4a13812ab5338 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.createclient.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ElasticsearchServiceStart](./kibana-plugin-core-server.elasticsearchservicestart.md) > [createClient](./kibana-plugin-core-server.elasticsearchservicestart.createclient.md) + +## ElasticsearchServiceStart.createClient property + +Create application specific Elasticsearch cluster API client with customized config. See [IClusterClient](./kibana-plugin-core-server.iclusterclient.md). + +Signature: + +```typescript +readonly createClient: (type: string, clientConfig?: Partial) => ICustomClusterClient; +``` + +## Example + + +```js +const client = elasticsearch.createClient('my-app-name', config); +const data = await client.asInternalUser.search(); + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.md index e059acdbd52fa2..860867d6544358 100644 --- a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.md +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.md @@ -15,5 +15,7 @@ export interface ElasticsearchServiceStart | Property | Type | Description | | --- | --- | --- | +| [client](./kibana-plugin-core-server.elasticsearchservicestart.client.md) | IClusterClient | A pre-configured [Elasticsearch client](./kibana-plugin-core-server.iclusterclient.md) | +| [createClient](./kibana-plugin-core-server.elasticsearchservicestart.createclient.md) | (type: string, clientConfig?: Partial<ElasticsearchClientConfig>) => ICustomClusterClient | Create application specific Elasticsearch cluster API client with customized config. See [IClusterClient](./kibana-plugin-core-server.iclusterclient.md). | | [legacy](./kibana-plugin-core-server.elasticsearchservicestart.legacy.md) | {
readonly createClient: (type: string, clientConfig?: Partial<LegacyElasticsearchClientConfig>) => ILegacyCustomClusterClient;
readonly client: ILegacyClusterClient;
} | | diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registerroutehandlercontext.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registerroutehandlercontext.md index 8958b49d98b0c7..b0dc4d44f75594 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registerroutehandlercontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registerroutehandlercontext.md @@ -21,7 +21,7 @@ registerRouteHandlerContext: (contextName 'myApp', (context, req) => { async function search (id: string) { - return await context.elasticsearch.legacy.client.callAsInternalUser('endpoint', id); + return await context.elasticsearch.client.asCurrentUser.find(id); } return { search }; } diff --git a/docs/development/core/server/kibana-plugin-core-server.iclusterclient.asinternaluser.md b/docs/development/core/server/kibana-plugin-core-server.iclusterclient.asinternaluser.md new file mode 100644 index 00000000000000..c7adc345af5a33 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iclusterclient.asinternaluser.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IClusterClient](./kibana-plugin-core-server.iclusterclient.md) > [asInternalUser](./kibana-plugin-core-server.iclusterclient.asinternaluser.md) + +## IClusterClient.asInternalUser property + +A [client](./kibana-plugin-core-server.elasticsearchclient.md) to be used to query the ES cluster on behalf of the Kibana internal user + +Signature: + +```typescript +readonly asInternalUser: ElasticsearchClient; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.iclusterclient.asscoped.md b/docs/development/core/server/kibana-plugin-core-server.iclusterclient.asscoped.md new file mode 100644 index 00000000000000..301fcbfee58581 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iclusterclient.asscoped.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IClusterClient](./kibana-plugin-core-server.iclusterclient.md) > [asScoped](./kibana-plugin-core-server.iclusterclient.asscoped.md) + +## IClusterClient.asScoped property + +Creates a [scoped cluster client](./kibana-plugin-core-server.iscopedclusterclient.md) bound to given [request](./kibana-plugin-core-server.scopeablerequest.md) + +Signature: + +```typescript +asScoped: (request: ScopeableRequest) => IScopedClusterClient; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.iclusterclient.md b/docs/development/core/server/kibana-plugin-core-server.iclusterclient.md new file mode 100644 index 00000000000000..f6bacee322538d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iclusterclient.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IClusterClient](./kibana-plugin-core-server.iclusterclient.md) + +## IClusterClient interface + +Represents an Elasticsearch cluster API client created by the platform. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via `asScoped(...)`). + +Signature: + +```typescript +export interface IClusterClient +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [asInternalUser](./kibana-plugin-core-server.iclusterclient.asinternaluser.md) | ElasticsearchClient | A [client](./kibana-plugin-core-server.elasticsearchclient.md) to be used to query the ES cluster on behalf of the Kibana internal user | +| [asScoped](./kibana-plugin-core-server.iclusterclient.asscoped.md) | (request: ScopeableRequest) => IScopedClusterClient | Creates a [scoped cluster client](./kibana-plugin-core-server.iscopedclusterclient.md) bound to given [request](./kibana-plugin-core-server.scopeablerequest.md) | + diff --git a/docs/development/core/server/kibana-plugin-core-server.icustomclusterclient.close.md b/docs/development/core/server/kibana-plugin-core-server.icustomclusterclient.close.md new file mode 100644 index 00000000000000..5fa2e93cca75bd --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.icustomclusterclient.close.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ICustomClusterClient](./kibana-plugin-core-server.icustomclusterclient.md) > [close](./kibana-plugin-core-server.icustomclusterclient.close.md) + +## ICustomClusterClient.close property + +Closes the cluster client. After that client cannot be used and one should create a new client instance to be able to interact with Elasticsearch API. + +Signature: + +```typescript +close: () => Promise; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.icustomclusterclient.md b/docs/development/core/server/kibana-plugin-core-server.icustomclusterclient.md new file mode 100644 index 00000000000000..189a50b5d6c20a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.icustomclusterclient.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ICustomClusterClient](./kibana-plugin-core-server.icustomclusterclient.md) + +## ICustomClusterClient interface + +See [IClusterClient](./kibana-plugin-core-server.iclusterclient.md) + +Signature: + +```typescript +export interface ICustomClusterClient extends IClusterClient +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [close](./kibana-plugin-core-server.icustomclusterclient.close.md) | () => Promise<void> | Closes the cluster client. After that client cannot be used and one should create a new client instance to be able to interact with Elasticsearch API. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.ilegacyclusterclient.md b/docs/development/core/server/kibana-plugin-core-server.ilegacyclusterclient.md index c70a5ac07c6ad8..b5fbb3d54b972a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.ilegacyclusterclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.ilegacyclusterclient.md @@ -4,6 +4,11 @@ ## ILegacyClusterClient type +> Warning: This API is now obsolete. +> +> Use [IClusterClient](./kibana-plugin-core-server.iclusterclient.md). +> + Represents an Elasticsearch cluster API client created by the platform. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via `asScoped(...)`). See [LegacyClusterClient](./kibana-plugin-core-server.legacyclusterclient.md). diff --git a/docs/development/core/server/kibana-plugin-core-server.ilegacycustomclusterclient.md b/docs/development/core/server/kibana-plugin-core-server.ilegacycustomclusterclient.md index a3cb8f13150212..4da121984d0847 100644 --- a/docs/development/core/server/kibana-plugin-core-server.ilegacycustomclusterclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.ilegacycustomclusterclient.md @@ -4,6 +4,11 @@ ## ILegacyCustomClusterClient type +> Warning: This API is now obsolete. +> +> Use [ICustomClusterClient](./kibana-plugin-core-server.icustomclusterclient.md). +> + Represents an Elasticsearch cluster API client created by a plugin. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via `asScoped(...)`). See [LegacyClusterClient](./kibana-plugin-core-server.legacyclusterclient.md). diff --git a/docs/development/core/server/kibana-plugin-core-server.ilegacyscopedclusterclient.md b/docs/development/core/server/kibana-plugin-core-server.ilegacyscopedclusterclient.md index 1263b85acb4983..51d0b2e4882cb6 100644 --- a/docs/development/core/server/kibana-plugin-core-server.ilegacyscopedclusterclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.ilegacyscopedclusterclient.md @@ -4,6 +4,11 @@ ## ILegacyScopedClusterClient type +> Warning: This API is now obsolete. +> +> Use [IScopedClusterClient](./kibana-plugin-core-server.iscopedclusterclient.md). +> + Serves the same purpose as "normal" `ClusterClient` but exposes additional `callAsCurrentUser` method that doesn't use credentials of the Kibana internal user (as `callAsInternalUser` does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API. See [LegacyScopedClusterClient](./kibana-plugin-core-server.legacyscopedclusterclient.md). diff --git a/docs/development/core/server/kibana-plugin-core-server.indexsettingsdeprecationinfo.md b/docs/development/core/server/kibana-plugin-core-server.indexsettingsdeprecationinfo.md index 00f16595490784..706898c4ad9aa2 100644 --- a/docs/development/core/server/kibana-plugin-core-server.indexsettingsdeprecationinfo.md +++ b/docs/development/core/server/kibana-plugin-core-server.indexsettingsdeprecationinfo.md @@ -4,6 +4,9 @@ ## IndexSettingsDeprecationInfo interface +> Warning: This API is now obsolete. +> +> Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.ascurrentuser.md b/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.ascurrentuser.md new file mode 100644 index 00000000000000..ddc6357bb8835e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.ascurrentuser.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IScopedClusterClient](./kibana-plugin-core-server.iscopedclusterclient.md) > [asCurrentUser](./kibana-plugin-core-server.iscopedclusterclient.ascurrentuser.md) + +## IScopedClusterClient.asCurrentUser property + +A [client](./kibana-plugin-core-server.elasticsearchclient.md) to be used to query the elasticsearch cluster on behalf of the user that initiated the request to the Kibana server. + +Signature: + +```typescript +readonly asCurrentUser: ElasticsearchClient; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.asinternaluser.md b/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.asinternaluser.md new file mode 100644 index 00000000000000..f7f308aa131614 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.asinternaluser.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IScopedClusterClient](./kibana-plugin-core-server.iscopedclusterclient.md) > [asInternalUser](./kibana-plugin-core-server.iscopedclusterclient.asinternaluser.md) + +## IScopedClusterClient.asInternalUser property + +A [client](./kibana-plugin-core-server.elasticsearchclient.md) to be used to query the elasticsearch cluster on behalf of the internal Kibana user. + +Signature: + +```typescript +readonly asInternalUser: ElasticsearchClient; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md b/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md new file mode 100644 index 00000000000000..f39db268288a6f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IScopedClusterClient](./kibana-plugin-core-server.iscopedclusterclient.md) + +## IScopedClusterClient interface + +Serves the same purpose as the normal [cluster client](./kibana-plugin-core-server.iclusterclient.md) but exposes an additional `asCurrentUser` method that doesn't use credentials of the Kibana internal user (as `asInternalUser` does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API instead. + +Signature: + +```typescript +export interface IScopedClusterClient +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [asCurrentUser](./kibana-plugin-core-server.iscopedclusterclient.ascurrentuser.md) | ElasticsearchClient | A [client](./kibana-plugin-core-server.elasticsearchclient.md) to be used to query the elasticsearch cluster on behalf of the user that initiated the request to the Kibana server. | +| [asInternalUser](./kibana-plugin-core-server.iscopedclusterclient.asinternaluser.md) | ElasticsearchClient | A [client](./kibana-plugin-core-server.elasticsearchclient.md) to be used to query the elasticsearch cluster on behalf of the internal Kibana user. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.legacyapicaller.md b/docs/development/core/server/kibana-plugin-core-server.legacyapicaller.md index e6c2878d2b3556..168209659046e7 100644 --- a/docs/development/core/server/kibana-plugin-core-server.legacyapicaller.md +++ b/docs/development/core/server/kibana-plugin-core-server.legacyapicaller.md @@ -4,6 +4,9 @@ ## LegacyAPICaller interface +> Warning: This API is now obsolete. +> +> Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.legacycallapioptions.md b/docs/development/core/server/kibana-plugin-core-server.legacycallapioptions.md index 9ebe2fc57a54bf..40def157114ef2 100644 --- a/docs/development/core/server/kibana-plugin-core-server.legacycallapioptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.legacycallapioptions.md @@ -4,6 +4,10 @@ ## LegacyCallAPIOptions interface +> Warning: This API is now obsolete. +> +> + The set of options that defines how API call should be made and result be processed. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient.md b/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient.md index c51f1858c97a57..668d0b2866a264 100644 --- a/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient.md @@ -4,6 +4,12 @@ ## LegacyClusterClient class +> Warning: This API is now obsolete. +> +> Use [IClusterClient](./kibana-plugin-core-server.iclusterclient.md). +> + +Represents an Elasticsearch cluster API client created by the platform. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via `asScoped(...)`). Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.legacyelasticsearchclientconfig.md b/docs/development/core/server/kibana-plugin-core-server.legacyelasticsearchclientconfig.md index 62b0f216c863c0..78f7bf582d355e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.legacyelasticsearchclientconfig.md +++ b/docs/development/core/server/kibana-plugin-core-server.legacyelasticsearchclientconfig.md @@ -4,6 +4,9 @@ ## LegacyElasticsearchClientConfig type +> Warning: This API is now obsolete. +> +> Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.legacyelasticsearcherror.md b/docs/development/core/server/kibana-plugin-core-server.legacyelasticsearcherror.md index f760780504e55f..40fc1a8e05a68b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.legacyelasticsearcherror.md +++ b/docs/development/core/server/kibana-plugin-core-server.legacyelasticsearcherror.md @@ -4,6 +4,7 @@ ## LegacyElasticsearchError interface +@deprecated. The new elasticsearch client doesn't wrap errors anymore. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.legacyscopedclusterclient.md b/docs/development/core/server/kibana-plugin-core-server.legacyscopedclusterclient.md index c4a94d8661c47b..7f752d70921ba7 100644 --- a/docs/development/core/server/kibana-plugin-core-server.legacyscopedclusterclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.legacyscopedclusterclient.md @@ -4,6 +4,12 @@ ## LegacyScopedClusterClient class +> Warning: This API is now obsolete. +> +> Use [scoped cluster client](./kibana-plugin-core-server.iscopedclusterclient.md). +> + +Serves the same purpose as the normal [cluster client](./kibana-plugin-core-server.iclusterclient.md) but exposes an additional `asCurrentUser` method that doesn't use credentials of the Kibana internal user (as `asInternalUser` does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API instead. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 95b7627398b45e..5347f0b55e19b4 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -20,9 +20,9 @@ The plugin integrates with the core system via lifecycle events: `setup` | [CspConfig](./kibana-plugin-core-server.cspconfig.md) | CSP configuration for use in Kibana. | | [ElasticsearchConfig](./kibana-plugin-core-server.elasticsearchconfig.md) | Wrapper of config schema. | | [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md) | Kibana specific abstraction for an incoming request. | -| [LegacyClusterClient](./kibana-plugin-core-server.legacyclusterclient.md) | | +| [LegacyClusterClient](./kibana-plugin-core-server.legacyclusterclient.md) | Represents an Elasticsearch cluster API client created by the platform. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)). | | [LegacyElasticsearchErrorHelpers](./kibana-plugin-core-server.legacyelasticsearcherrorhelpers.md) | Helpers for working with errors returned from the Elasticsearch service.Since the internal data of errors are subject to change, consumers of the Elasticsearch service should always use these helpers to classify errors instead of checking error internals such as body.error.header[WWW-Authenticate] | -| [LegacyScopedClusterClient](./kibana-plugin-core-server.legacyscopedclusterclient.md) | | +| [LegacyScopedClusterClient](./kibana-plugin-core-server.legacyscopedclusterclient.md) | Serves the same purpose as the normal [cluster client](./kibana-plugin-core-server.iclusterclient.md) but exposes an additional asCurrentUser method that doesn't use credentials of the Kibana internal user (as asInternalUser does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API instead. | | [RouteValidationError](./kibana-plugin-core-server.routevalidationerror.md) | Error to return when the validation is not successful. | | [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) | | | [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) | | @@ -98,20 +98,23 @@ The plugin integrates with the core system via lifecycle events: `setup` | [HttpServerInfo](./kibana-plugin-core-server.httpserverinfo.md) | | | [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) | Kibana HTTP Service provides own abstraction for work with HTTP stack. Plugins don't have direct access to hapi server and its primitives anymore. Moreover, plugins shouldn't rely on the fact that HTTP Service uses one or another library under the hood. This gives the platform flexibility to upgrade or changing our internal HTTP stack without breaking plugins. If the HTTP Service lacks functionality you need, we are happy to discuss and support your needs. | | [HttpServiceStart](./kibana-plugin-core-server.httpservicestart.md) | | +| [IClusterClient](./kibana-plugin-core-server.iclusterclient.md) | Represents an Elasticsearch cluster API client created by the platform. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)). | | [IContextContainer](./kibana-plugin-core-server.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | | [ICspConfig](./kibana-plugin-core-server.icspconfig.md) | CSP configuration for use in Kibana. | +| [ICustomClusterClient](./kibana-plugin-core-server.icustomclusterclient.md) | See [IClusterClient](./kibana-plugin-core-server.iclusterclient.md) | | [IKibanaResponse](./kibana-plugin-core-server.ikibanaresponse.md) | A response data object, expected to returned as a result of [RequestHandler](./kibana-plugin-core-server.requesthandler.md) execution | | [IKibanaSocket](./kibana-plugin-core-server.ikibanasocket.md) | A tiny abstraction for TCP socket. | | [ImageValidation](./kibana-plugin-core-server.imagevalidation.md) | | | [IndexSettingsDeprecationInfo](./kibana-plugin-core-server.indexsettingsdeprecationinfo.md) | | | [IRenderOptions](./kibana-plugin-core-server.irenderoptions.md) | | | [IRouter](./kibana-plugin-core-server.irouter.md) | Registers route handlers for specified resource path and method. See [RouteConfig](./kibana-plugin-core-server.routeconfig.md) and [RequestHandler](./kibana-plugin-core-server.requesthandler.md) for more information about arguments to route registrations. | +| [IScopedClusterClient](./kibana-plugin-core-server.iscopedclusterclient.md) | Serves the same purpose as the normal [cluster client](./kibana-plugin-core-server.iclusterclient.md) but exposes an additional asCurrentUser method that doesn't use credentials of the Kibana internal user (as asInternalUser does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API instead. | | [IUiSettingsClient](./kibana-plugin-core-server.iuisettingsclient.md) | Server-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. | | [KibanaRequestEvents](./kibana-plugin-core-server.kibanarequestevents.md) | Request events. | | [KibanaRequestRoute](./kibana-plugin-core-server.kibanarequestroute.md) | Request specific route information exposed to a handler. | | [LegacyAPICaller](./kibana-plugin-core-server.legacyapicaller.md) | | | [LegacyCallAPIOptions](./kibana-plugin-core-server.legacycallapioptions.md) | The set of options that defines how API call should be made and result be processed. | -| [LegacyElasticsearchError](./kibana-plugin-core-server.legacyelasticsearcherror.md) | | +| [LegacyElasticsearchError](./kibana-plugin-core-server.legacyelasticsearcherror.md) | @deprecated. The new elasticsearch client doesn't wrap errors anymore. | | [LegacyRequest](./kibana-plugin-core-server.legacyrequest.md) | | | [LegacyServiceSetupDeps](./kibana-plugin-core-server.legacyservicesetupdeps.md) | | | [LegacyServiceStartDeps](./kibana-plugin-core-server.legacyservicestartdeps.md) | | @@ -137,7 +140,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [PluginConfigDescriptor](./kibana-plugin-core-server.pluginconfigdescriptor.md) | Describes a plugin configuration properties. | | [PluginInitializerContext](./kibana-plugin-core-server.plugininitializercontext.md) | Context that's available to plugins during initialization stage. | | [PluginManifest](./kibana-plugin-core-server.pluginmanifest.md) | Describes the set of required and optional properties plugin can define in its mandatory JSON manifest file. | -| [RequestHandlerContext](./kibana-plugin-core-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.legacy.client](./kibana-plugin-core-server.legacyscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request | +| [RequestHandlerContext](./kibana-plugin-core-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.client](./kibana-plugin-core-server.iscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.legacy.client](./kibana-plugin-core-server.legacyscopedclusterclient.md) - The legacy Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request - [uiSettings.auditor](./kibana-plugin-core-server.auditor.md) - AuditTrail client scoped to the incoming request | | [RouteConfig](./kibana-plugin-core-server.routeconfig.md) | Route specific configuration. | | [RouteConfigOptions](./kibana-plugin-core-server.routeconfigoptions.md) | Additional route options. | | [RouteConfigOptionsBody](./kibana-plugin-core-server.routeconfigoptionsbody.md) | Additional body options for a route | @@ -236,6 +239,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ConfigPath](./kibana-plugin-core-server.configpath.md) | | | [DestructiveRouteMethod](./kibana-plugin-core-server.destructiveroutemethod.md) | Set of HTTP methods changing the state of the server. | | [ElasticsearchClient](./kibana-plugin-core-server.elasticsearchclient.md) | Client used to query the elasticsearch cluster. | +| [ElasticsearchClientConfig](./kibana-plugin-core-server.elasticsearchclientconfig.md) | Configuration options to be used to create a [cluster client](./kibana-plugin-core-server.iclusterclient.md) using the [createClient API](./kibana-plugin-core-server.elasticsearchservicestart.createclient.md) | | [Freezable](./kibana-plugin-core-server.freezable.md) | | | [GetAuthHeaders](./kibana-plugin-core-server.getauthheaders.md) | Get headers to authenticate a user against Elasticsearch. | | [GetAuthState](./kibana-plugin-core-server.getauthstate.md) | Gets authentication state for a request. Returned by auth interceptor. | diff --git a/docs/development/core/server/kibana-plugin-core-server.migration_assistance_index_action.md b/docs/development/core/server/kibana-plugin-core-server.migration_assistance_index_action.md index 69fb573d365300..a924f0cea6b6b0 100644 --- a/docs/development/core/server/kibana-plugin-core-server.migration_assistance_index_action.md +++ b/docs/development/core/server/kibana-plugin-core-server.migration_assistance_index_action.md @@ -4,6 +4,9 @@ ## MIGRATION\_ASSISTANCE\_INDEX\_ACTION type +> Warning: This API is now obsolete. +> +> Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.migration_deprecation_level.md b/docs/development/core/server/kibana-plugin-core-server.migration_deprecation_level.md index c3256eaa783314..0fcae8c847cb40 100644 --- a/docs/development/core/server/kibana-plugin-core-server.migration_deprecation_level.md +++ b/docs/development/core/server/kibana-plugin-core-server.migration_deprecation_level.md @@ -4,6 +4,9 @@ ## MIGRATION\_DEPRECATION\_LEVEL type +> Warning: This API is now obsolete. +> +> Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md index 2d31c24a077cbf..5b8492ec5ece1b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md @@ -13,6 +13,7 @@ core: { typeRegistry: ISavedObjectTypeRegistry; }; elasticsearch: { + client: IScopedClusterClient; legacy: { client: ILegacyScopedClusterClient; }; diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md index 07e6dcbdae125e..4e530973f9d50a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md @@ -6,7 +6,7 @@ Plugin specific context passed to a route handler. -Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.legacy.client](./kibana-plugin-core-server.legacyscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request +Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.client](./kibana-plugin-core-server.iscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.legacy.client](./kibana-plugin-core-server.legacyscopedclusterclient.md) - The legacy Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request - [uiSettings.auditor](./kibana-plugin-core-server.auditor.md) - AuditTrail client scoped to the incoming request Signature: @@ -18,5 +18,5 @@ export interface RequestHandlerContext | Property | Type | Description | | --- | --- | --- | -| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | {
savedObjects: {
client: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
};
elasticsearch: {
legacy: {
client: ILegacyScopedClusterClient;
};
};
uiSettings: {
client: IUiSettingsClient;
};
auditor: Auditor;
} | | +| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | {
savedObjects: {
client: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
};
elasticsearch: {
client: IScopedClusterClient;
legacy: {
client: ILegacyScopedClusterClient;
};
};
uiSettings: {
client: IUiSettingsClient;
};
auditor: Auditor;
} | | diff --git a/scripts/functional_tests.js b/scripts/functional_tests.js index 3fdab481dc7500..4facbe1ffbb07e 100644 --- a/scripts/functional_tests.js +++ b/scripts/functional_tests.js @@ -20,7 +20,7 @@ // eslint-disable-next-line no-restricted-syntax const alwaysImportedTests = [ require.resolve('../test/functional/config.js'), - require.resolve('../test/plugin_functional/config.js'), + require.resolve('../test/plugin_functional/config.ts'), require.resolve('../test/ui_capabilities/newsfeed_err/config.ts'), require.resolve('../test/new_visualize_flow/config.js'), ]; diff --git a/src/core/server/elasticsearch/client/mocks.ts b/src/core/server/elasticsearch/client/mocks.ts index c93294404b52ff..2f2ca08fee6f20 100644 --- a/src/core/server/elasticsearch/client/mocks.ts +++ b/src/core/server/elasticsearch/client/mocks.ts @@ -70,15 +70,14 @@ const createInternalClientMock = (): DeeplyMockedKeys => { return (mock as unknown) as DeeplyMockedKeys; }; -// TODO fix naming ElasticsearchClientMock -export type ElasticSearchClientMock = DeeplyMockedKeys; +export type ElasticsearchClientMock = DeeplyMockedKeys; -const createClientMock = (): ElasticSearchClientMock => - (createInternalClientMock() as unknown) as ElasticSearchClientMock; +const createClientMock = (): ElasticsearchClientMock => + (createInternalClientMock() as unknown) as ElasticsearchClientMock; interface ScopedClusterClientMock { - asInternalUser: ElasticSearchClientMock; - asCurrentUser: ElasticSearchClientMock; + asInternalUser: ElasticsearchClientMock; + asCurrentUser: ElasticsearchClientMock; } const createScopedClusterClientMock = () => { @@ -91,7 +90,7 @@ const createScopedClusterClientMock = () => { }; export interface ClusterClientMock { - asInternalUser: ElasticSearchClientMock; + asInternalUser: ElasticsearchClientMock; asScoped: jest.MockedFunction<() => ScopedClusterClientMock>; } @@ -157,7 +156,7 @@ export const elasticsearchClientMock = { createClusterClient: createClusterClientMock, createCustomClusterClient: createCustomClusterClientMock, createScopedClusterClient: createScopedClusterClientMock, - createElasticSearchClient: createClientMock, + createElasticsearchClient: createClientMock, createInternalClient: createInternalClientMock, createSuccessTransportRequestPromise, createErrorTransportRequestPromise, diff --git a/src/core/server/elasticsearch/client/retry_call_cluster.test.ts b/src/core/server/elasticsearch/client/retry_call_cluster.test.ts index 3aa47e8b40e24e..c9366c575ba743 100644 --- a/src/core/server/elasticsearch/client/retry_call_cluster.test.ts +++ b/src/core/server/elasticsearch/client/retry_call_cluster.test.ts @@ -27,10 +27,10 @@ const createErrorReturn = (err: any) => elasticsearchClientMock.createErrorTransportRequestPromise(err); describe('retryCallCluster', () => { - let client: ReturnType; + let client: ReturnType; beforeEach(() => { - client = elasticsearchClientMock.createElasticSearchClient(); + client = elasticsearchClientMock.createElasticsearchClient(); }); it('returns response from ES API call in case of success', async () => { @@ -91,11 +91,11 @@ describe('retryCallCluster', () => { }); describe('migrationRetryCallCluster', () => { - let client: ReturnType; + let client: ReturnType; let logger: ReturnType; beforeEach(() => { - client = elasticsearchClientMock.createElasticSearchClient(); + client = elasticsearchClientMock.createElasticsearchClient(); logger = loggingSystemMock.createLogger(); }); diff --git a/src/core/server/elasticsearch/client/scoped_cluster_client.test.ts b/src/core/server/elasticsearch/client/scoped_cluster_client.test.ts index 78ca8fcbd3c073..4288c6bf6421d7 100644 --- a/src/core/server/elasticsearch/client/scoped_cluster_client.test.ts +++ b/src/core/server/elasticsearch/client/scoped_cluster_client.test.ts @@ -22,8 +22,8 @@ import { ScopedClusterClient } from './scoped_cluster_client'; describe('ScopedClusterClient', () => { it('uses the internal client passed in the constructor', () => { - const internalClient = elasticsearchClientMock.createElasticSearchClient(); - const scopedClient = elasticsearchClientMock.createElasticSearchClient(); + const internalClient = elasticsearchClientMock.createElasticsearchClient(); + const scopedClient = elasticsearchClientMock.createElasticsearchClient(); const scopedClusterClient = new ScopedClusterClient(internalClient, scopedClient); @@ -31,8 +31,8 @@ describe('ScopedClusterClient', () => { }); it('uses the scoped client passed in the constructor', () => { - const internalClient = elasticsearchClientMock.createElasticSearchClient(); - const scopedClient = elasticsearchClientMock.createElasticSearchClient(); + const internalClient = elasticsearchClientMock.createElasticsearchClient(); + const scopedClient = elasticsearchClientMock.createElasticsearchClient(); const scopedClusterClient = new ScopedClusterClient(internalClient, scopedClient); diff --git a/src/core/server/elasticsearch/client/scoped_cluster_client.ts b/src/core/server/elasticsearch/client/scoped_cluster_client.ts index 1af7948a65e168..05ab67073f9e16 100644 --- a/src/core/server/elasticsearch/client/scoped_cluster_client.ts +++ b/src/core/server/elasticsearch/client/scoped_cluster_client.ts @@ -20,7 +20,7 @@ import { ElasticsearchClient } from './types'; /** - * Serves the same purpose as the normal {@link ClusterClient | cluster client} but exposes + * Serves the same purpose as the normal {@link IClusterClient | cluster client} but exposes * an additional `asCurrentUser` method that doesn't use credentials of the Kibana internal * user (as `asInternalUser` does) to request Elasticsearch API, but rather passes HTTP headers * extracted from the current user request to the API instead. diff --git a/src/core/server/elasticsearch/elasticsearch_service.mock.ts b/src/core/server/elasticsearch/elasticsearch_service.mock.ts index b97f6df6b0afcf..501ab619316c22 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.mock.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.mock.ts @@ -24,6 +24,7 @@ import { ClusterClientMock, CustomClusterClientMock, } from './client/mocks'; +import { ElasticsearchClientConfig } from './client'; import { legacyClientMock } from './legacy/mocks'; import { ElasticsearchConfig } from './elasticsearch_config'; import { ElasticsearchService } from './elasticsearch_service'; @@ -38,12 +39,12 @@ interface MockedElasticSearchServiceSetup { }; } -type MockedElasticSearchServiceStart = MockedElasticSearchServiceSetup; - -interface MockedInternalElasticSearchServiceStart extends MockedElasticSearchServiceStart { +type MockedElasticSearchServiceStart = MockedElasticSearchServiceSetup & { client: ClusterClientMock; - createClient: jest.MockedFunction<() => CustomClusterClientMock>; -} + createClient: jest.MockedFunction< + (name: string, config?: Partial) => CustomClusterClientMock + >; +}; const createSetupContractMock = () => { const setupContract: MockedElasticSearchServiceSetup = { @@ -61,6 +62,8 @@ const createSetupContractMock = () => { const createStartContractMock = () => { const startContract: MockedElasticSearchServiceStart = { + client: elasticsearchClientMock.createClusterClient(), + createClient: jest.fn(), legacy: { createClient: jest.fn(), client: legacyClientMock.createClusterClient(), @@ -70,20 +73,13 @@ const createStartContractMock = () => { startContract.legacy.client.asScoped.mockReturnValue( legacyClientMock.createScopedClusterClient() ); + startContract.createClient.mockImplementation(() => + elasticsearchClientMock.createCustomClusterClient() + ); return startContract; }; -const createInternalStartContractMock = () => { - const startContract: MockedInternalElasticSearchServiceStart = { - ...createStartContractMock(), - client: elasticsearchClientMock.createClusterClient(), - createClient: jest.fn(), - }; - - startContract.createClient.mockReturnValue(elasticsearchClientMock.createCustomClusterClient()); - - return startContract; -}; +const createInternalStartContractMock = createStartContractMock; type MockedInternalElasticSearchServiceSetup = jest.Mocked< InternalElasticsearchServiceSetup & { @@ -136,4 +132,6 @@ export const elasticsearchServiceMock = { createLegacyCustomClusterClient: legacyClientMock.createCustomClusterClient, createLegacyScopedClusterClient: legacyClientMock.createScopedClusterClient, createLegacyElasticsearchClient: legacyClientMock.createElasticsearchClient, + + ...elasticsearchClientMock, }; diff --git a/src/core/server/elasticsearch/legacy/api_types.ts b/src/core/server/elasticsearch/legacy/api_types.ts index b9699ab290e3fc..896a58e085d49b 100644 --- a/src/core/server/elasticsearch/legacy/api_types.ts +++ b/src/core/server/elasticsearch/legacy/api_types.ts @@ -150,6 +150,7 @@ import { * processed. * * @public + * @deprecated */ export interface LegacyCallAPIOptions { /** @@ -165,7 +166,10 @@ export interface LegacyCallAPIOptions { signal?: AbortSignal; } -/** @public */ +/** + * @deprecated + * @public + * */ export interface LegacyAPICaller { /* eslint-disable */ (endpoint: 'bulk', params: BulkIndexDocumentsParams, options?: LegacyCallAPIOptions): ReturnType; @@ -317,18 +321,30 @@ export interface LegacyAPICaller { /* eslint-enable */ } -/** @public */ +/** + * @deprecated + * @public + * */ export interface AssistantAPIClientParams extends GenericParams { path: '/_migration/assistance'; method: 'GET'; } -/** @public */ +/** + * @deprecated + * @public + * */ export type MIGRATION_ASSISTANCE_INDEX_ACTION = 'upgrade' | 'reindex'; -/** @public */ +/** + * @deprecated + * @public + * */ export type MIGRATION_DEPRECATION_LEVEL = 'none' | 'info' | 'warning' | 'critical'; -/** @public */ +/** + * @deprecated + * @public + * */ export interface AssistanceAPIResponse { indices: { [indexName: string]: { @@ -337,13 +353,19 @@ export interface AssistanceAPIResponse { }; } -/** @public */ +/** + * @deprecated + * @public + * */ export interface DeprecationAPIClientParams extends GenericParams { path: '/_migration/deprecations'; method: 'GET'; } -/** @public */ +/** + * @deprecated + * @public + * */ export interface DeprecationInfo { level: MIGRATION_DEPRECATION_LEVEL; message: string; @@ -351,12 +373,18 @@ export interface DeprecationInfo { details?: string; } -/** @public */ +/** + * @deprecated + * @public + * */ export interface IndexSettingsDeprecationInfo { [indexName: string]: DeprecationInfo[]; } -/** @public */ +/** + * @deprecated + * @public + * */ export interface DeprecationAPIResponse { cluster_settings: DeprecationInfo[]; ml_settings: DeprecationInfo[]; diff --git a/src/core/server/elasticsearch/legacy/cluster_client.ts b/src/core/server/elasticsearch/legacy/cluster_client.ts index 7a39113d25a14d..f8b2d39a4251c0 100644 --- a/src/core/server/elasticsearch/legacy/cluster_client.ts +++ b/src/core/server/elasticsearch/legacy/cluster_client.ts @@ -88,6 +88,7 @@ const callAPI = async ( * * See {@link LegacyClusterClient}. * + * @deprecated Use {@link IClusterClient}. * @public */ export type ILegacyClusterClient = Pick; @@ -98,7 +99,7 @@ export type ILegacyClusterClient = Pick & diff --git a/src/core/server/elasticsearch/legacy/errors.ts b/src/core/server/elasticsearch/legacy/errors.ts index 3b3b8da51a9075..de4d2739977bb7 100644 --- a/src/core/server/elasticsearch/legacy/errors.ts +++ b/src/core/server/elasticsearch/legacy/errors.ts @@ -26,7 +26,10 @@ enum ErrorCode { NOT_AUTHORIZED = 'Elasticsearch/notAuthorized', } -/** @public */ +/** + * @deprecated. The new elasticsearch client doesn't wrap errors anymore. + * @public + * */ export interface LegacyElasticsearchError extends Boom { [code]?: string; } diff --git a/src/core/server/elasticsearch/legacy/scoped_cluster_client.ts b/src/core/server/elasticsearch/legacy/scoped_cluster_client.ts index 9edb73645f0e2c..aee7a1daa81668 100644 --- a/src/core/server/elasticsearch/legacy/scoped_cluster_client.ts +++ b/src/core/server/elasticsearch/legacy/scoped_cluster_client.ts @@ -30,6 +30,7 @@ import { LegacyAPICaller, LegacyCallAPIOptions } from './api_types'; * * See {@link LegacyScopedClusterClient}. * + * @deprecated Use {@link IScopedClusterClient}. * @public */ export type ILegacyScopedClusterClient = Pick< @@ -39,6 +40,7 @@ export type ILegacyScopedClusterClient = Pick< /** * {@inheritDoc IScopedClusterClient} + * @deprecated Use {@link IScopedClusterClient | scoped cluster client}. * @public */ export class LegacyScopedClusterClient implements ILegacyScopedClusterClient { diff --git a/src/core/server/elasticsearch/types.ts b/src/core/server/elasticsearch/types.ts index 40399aecbc4466..88094af8047e7c 100644 --- a/src/core/server/elasticsearch/types.ts +++ b/src/core/server/elasticsearch/types.ts @@ -95,6 +95,37 @@ export interface InternalElasticsearchServiceSetup { * @public */ export interface ElasticsearchServiceStart { + /** + * A pre-configured {@link IClusterClient | Elasticsearch client} + * + * @example + * ```js + * const client = core.elasticsearch.client; + * ``` + */ + readonly client: IClusterClient; + /** + * Create application specific Elasticsearch cluster API client with customized config. See {@link IClusterClient}. + * + * @param type Unique identifier of the client + * @param clientConfig A config consists of Elasticsearch JS client options and + * valid sub-set of Elasticsearch service config. + * We fill all the missing properties in the `clientConfig` using the default + * Elasticsearch config so that we don't depend on default values set and + * controlled by underlying Elasticsearch JS client. + * We don't run validation against the passed config and expect it to be valid. + * + * @example + * ```js + * const client = elasticsearch.createClient('my-app-name', config); + * const data = await client.asInternalUser.search(); + * ``` + */ + readonly createClient: ( + type: string, + clientConfig?: Partial + ) => ICustomClusterClient; + /** * @deprecated * Provided for the backward compatibility. @@ -138,38 +169,7 @@ export interface ElasticsearchServiceStart { /** * @internal */ -export interface InternalElasticsearchServiceStart extends ElasticsearchServiceStart { - /** - * A pre-configured {@link IClusterClient | Elasticsearch client} - * - * @example - * ```js - * const client = core.elasticsearch.client; - * ``` - */ - readonly client: IClusterClient; - /** - * Create application specific Elasticsearch cluster API client with customized config. See {@link IClusterClient}. - * - * @param type Unique identifier of the client - * @param clientConfig A config consists of Elasticsearch JS client options and - * valid sub-set of Elasticsearch service config. - * We fill all the missing properties in the `clientConfig` using the default - * Elasticsearch config so that we don't depend on default values set and - * controlled by underlying Elasticsearch JS client. - * We don't run validation against the passed config and expect it to be valid. - * - * @example - * ```js - * const client = elasticsearch.createClient('my-app-name', config); - * const data = await client.asInternalUser().search(); - * ``` - */ - readonly createClient: ( - type: string, - clientConfig?: Partial - ) => ICustomClusterClient; -} +export type InternalElasticsearchServiceStart = ElasticsearchServiceStart; /** @public */ export interface ElasticsearchStatusMeta { diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index 3df098a1df00d6..4345783e46e110 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -250,7 +250,7 @@ export interface HttpServiceSetup { * 'myApp', * (context, req) => { * async function search (id: string) { - * return await context.elasticsearch.legacy.client.callAsInternalUser('endpoint', id); + * return await context.elasticsearch.client.asCurrentUser.find(id); * } * return { search }; * } diff --git a/src/core/server/index.ts b/src/core/server/index.ts index f46b41d6b87938..382318ea86a343 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -44,6 +44,7 @@ import { ILegacyScopedClusterClient, configSchema as elasticsearchConfigSchema, ElasticsearchServiceStart, + IScopedClusterClient, } from './elasticsearch'; import { HttpServiceSetup, HttpServiceStart } from './http'; @@ -110,6 +111,10 @@ export { FakeRequest, ScopeableRequest, ElasticsearchClient, + IClusterClient, + ICustomClusterClient, + ElasticsearchClientConfig, + IScopedClusterClient, SearchResponse, CountResponse, ShardsInfo, @@ -367,10 +372,13 @@ export { * which uses the credentials of the incoming request * - {@link ISavedObjectTypeRegistry | savedObjects.typeRegistry} - Type registry containing * all the registered types. - * - {@link LegacyScopedClusterClient | elasticsearch.legacy.client} - Elasticsearch + * - {@link IScopedClusterClient | elasticsearch.client} - Elasticsearch + * data client which uses the credentials of the incoming request + * - {@link LegacyScopedClusterClient | elasticsearch.legacy.client} - The legacy Elasticsearch * data client which uses the credentials of the incoming request * - {@link IUiSettingsClient | uiSettings.client} - uiSettings client * which uses the credentials of the incoming request + * - {@link Auditor | uiSettings.auditor} - AuditTrail client scoped to the incoming request * * @public */ @@ -381,6 +389,7 @@ export interface RequestHandlerContext { typeRegistry: ISavedObjectTypeRegistry; }; elasticsearch: { + client: IScopedClusterClient; legacy: { client: ILegacyScopedClusterClient; }; diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 84e4b4741b717c..bf9dcc4abe01c1 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -193,6 +193,7 @@ function createCoreRequestHandlerContextMock() { typeRegistry: savedObjectsTypeRegistryMock.create(), }, elasticsearch: { + client: elasticsearchServiceMock.createScopedClusterClient(), legacy: { client: elasticsearchServiceMock.createLegacyScopedClusterClient(), }, diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index c17b8df8bb52c0..5235f3ee6d580d 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -212,6 +212,8 @@ export function createPluginStartContext( resolveCapabilities: deps.capabilities.resolveCapabilities, }, elasticsearch: { + client: deps.elasticsearch.client, + createClient: deps.elasticsearch.createClient, legacy: deps.elasticsearch.legacy, }, http: { diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts index fb8fb4ef950811..0b3ad1b6e3cc89 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts @@ -22,10 +22,10 @@ import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import * as Index from './elastic_index'; describe('ElasticIndex', () => { - let client: ReturnType; + let client: ReturnType; beforeEach(() => { - client = elasticsearchClientMock.createElasticSearchClient(); + client = elasticsearchClientMock.createElasticsearchClient(); }); describe('fetchInfo', () => { test('it handles 404', async () => { diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index 78601d033f8d8b..b0669774207dd9 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -27,13 +27,13 @@ import { loggingSystemMock } from '../../../logging/logging_system.mock'; describe('IndexMigrator', () => { let testOpts: jest.Mocked & { - client: ReturnType; + client: ReturnType; }; beforeEach(() => { testOpts = { batchSize: 10, - client: elasticsearchClientMock.createElasticSearchClient(), + client: elasticsearchClientMock.createElasticsearchClient(), index: '.kibana', log: loggingSystemMock.create().get(), mappingProperties: {}, @@ -366,7 +366,7 @@ describe('IndexMigrator', () => { }); function withIndex( - client: ReturnType, + client: ReturnType, opts: any = {} ) { const defaultIndex = { diff --git a/src/core/server/saved_objects/migrations/core/migration_es_client.test.ts b/src/core/server/saved_objects/migrations/core/migration_es_client.test.ts index 40c06677c4a5a5..a6da62095060cd 100644 --- a/src/core/server/saved_objects/migrations/core/migration_es_client.test.ts +++ b/src/core/server/saved_objects/migrations/core/migration_es_client.test.ts @@ -24,11 +24,11 @@ import { loggerMock } from '../../../logging/logger.mock'; import { SavedObjectsErrorHelpers } from '../../service/lib/errors'; describe('MigrationEsClient', () => { - let client: ReturnType; + let client: ReturnType; let migrationEsClient: MigrationEsClient; beforeEach(() => { - client = elasticsearchClientMock.createElasticSearchClient(); + client = elasticsearchClientMock.createElasticsearchClient(); migrationEsClient = createMigrationEsClient(client, loggerMock.create()); migrationRetryCallClusterMock.mockClear(); }); diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts index c3ed97a89af800..cc443093e30a34 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts @@ -127,7 +127,7 @@ describe('KibanaMigrator', () => { }); type MockedOptions = KibanaMigratorOptions & { - client: ReturnType; + client: ReturnType; }; const mockOptions = () => { @@ -170,7 +170,7 @@ const mockOptions = () => { scrollDuration: '10m', skip: false, }, - client: elasticsearchClientMock.createElasticSearchClient(), + client: elasticsearchClientMock.createElasticsearchClient(), }; return options; }; diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index b902179b012ff2..4a9fceb9bf3578 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -201,7 +201,7 @@ describe('SavedObjectsRepository', () => { }; beforeEach(() => { - client = elasticsearchClientMock.createElasticSearchClient(); + client = elasticsearchClientMock.createElasticsearchClient(); migrator = { migrateDocument: jest.fn().mockImplementation(documentMigrator.migrate), runMigrations: async () => ({ status: 'skipped' }), diff --git a/src/core/server/saved_objects/service/lib/repository_es_client.test.ts b/src/core/server/saved_objects/service/lib/repository_es_client.test.ts index 86a984fb671246..61df94fb6bfe2e 100644 --- a/src/core/server/saved_objects/service/lib/repository_es_client.test.ts +++ b/src/core/server/saved_objects/service/lib/repository_es_client.test.ts @@ -23,11 +23,11 @@ import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { SavedObjectsErrorHelpers } from './errors'; describe('RepositoryEsClient', () => { - let client: ReturnType; + let client: ReturnType; let repositoryClient: RepositoryEsClient; beforeEach(() => { - client = elasticsearchClientMock.createElasticSearchClient(); + client = elasticsearchClientMock.createElasticsearchClient(); repositoryClient = createRepositoryEsClient(client); retryCallClusterMock.mockClear(); }); diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index c94151f8cee179..c1054c27d084e4 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -158,7 +158,7 @@ export type AppenderConfigType = TypeOf; // @public export function assertNever(x: never): never; -// @public (undocumented) +// @public @deprecated (undocumented) export interface AssistanceAPIResponse { // (undocumented) indices: { @@ -168,7 +168,7 @@ export interface AssistanceAPIResponse { }; } -// @public (undocumented) +// @public @deprecated (undocumented) export interface AssistantAPIClientParams extends GenericParams { // (undocumented) method: 'GET'; @@ -622,7 +622,7 @@ export interface DeleteDocumentResponse { _version: number; } -// @public (undocumented) +// @public @deprecated (undocumented) export interface DeprecationAPIClientParams extends GenericParams { // (undocumented) method: 'GET'; @@ -630,7 +630,7 @@ export interface DeprecationAPIClientParams extends GenericParams { path: '/_migration/deprecations'; } -// @public (undocumented) +// @public @deprecated (undocumented) export interface DeprecationAPIResponse { // (undocumented) cluster_settings: DeprecationInfo[]; @@ -642,7 +642,7 @@ export interface DeprecationAPIResponse { node_settings: DeprecationInfo[]; } -// @public (undocumented) +// @public @deprecated (undocumented) export interface DeprecationInfo { // (undocumented) details?: string; @@ -679,6 +679,14 @@ export type ElasticsearchClient = Omit & { + pingTimeout?: ElasticsearchConfig['pingTimeout'] | ClientOptions['pingTimeout']; + requestTimeout?: ElasticsearchConfig['requestTimeout'] | ClientOptions['requestTimeout']; + ssl?: Partial; + keepAlive?: boolean; +}; + // @public export class ElasticsearchConfig { constructor(rawConfig: ElasticsearchConfigType); @@ -715,6 +723,8 @@ export interface ElasticsearchServiceSetup { // @public (undocumented) export interface ElasticsearchServiceStart { + readonly client: IClusterClient; + readonly createClient: (type: string, clientConfig?: Partial) => ICustomClusterClient; // @deprecated (undocumented) legacy: { readonly createClient: (type: string, clientConfig?: Partial) => ILegacyCustomClusterClient; @@ -895,6 +905,12 @@ export interface HttpServiceStart { // @public export type IBasePath = Pick; +// @public +export interface IClusterClient { + readonly asInternalUser: ElasticsearchClient; + asScoped: (request: ScopeableRequest) => IScopedClusterClient; +} + // @public export interface IContextContainer> { createHandler(pluginOpaqueId: PluginOpaqueId, handler: THandler): (...rest: HandlerParameters) => ShallowPromise>; @@ -914,6 +930,11 @@ export interface ICspConfig { readonly warnLegacyBrowsers: boolean; } +// @public +export interface ICustomClusterClient extends IClusterClient { + close: () => Promise; +} + // @public export interface IKibanaResponse { // (undocumented) @@ -935,13 +956,13 @@ export interface IKibanaSocket { getPeerCertificate(detailed?: boolean): PeerCertificate | DetailedPeerCertificate | null; } -// @public +// @public @deprecated export type ILegacyClusterClient = Pick; -// @public +// @public @deprecated export type ILegacyCustomClusterClient = Pick; -// @public +// @public @deprecated export type ILegacyScopedClusterClient = Pick; // @public (undocumented) @@ -956,7 +977,7 @@ export interface ImageValidation { // @public export function importSavedObjectsFromStream({ readStream, objectLimit, overwrite, savedObjectsClient, supportedTypes, namespace, }: SavedObjectsImportOptions): Promise; -// @public (undocumented) +// @public @deprecated (undocumented) export interface IndexSettingsDeprecationInfo { // (undocumented) [indexName: string]: DeprecationInfo[]; @@ -997,6 +1018,12 @@ export type ISavedObjectsRepository = Pick; +// @public +export interface IScopedClusterClient { + readonly asCurrentUser: ElasticsearchClient; + readonly asInternalUser: ElasticsearchClient; +} + // @public export function isRelativeUrl(candidatePath: string): boolean; @@ -1086,7 +1113,7 @@ export const kibanaResponseFactory: { // @public export type KnownHeaders = KnownKeys; -// @public (undocumented) +// @public @deprecated (undocumented) export interface LegacyAPICaller { // (undocumented) (endpoint: 'bulk', params: BulkIndexDocumentsParams, options?: LegacyCallAPIOptions): ReturnType; @@ -1330,15 +1357,13 @@ export interface LegacyAPICaller { (endpoint: string, clientParams?: Record, options?: LegacyCallAPIOptions): Promise; } -// @public +// @public @deprecated export interface LegacyCallAPIOptions { signal?: AbortSignal; wrap401Errors?: boolean; } -// Warning: (ae-unresolved-inheritdoc-reference) The @inheritDoc reference could not be resolved: The package "kibana" does not have an export "IClusterClient" -// -// @public (undocumented) +// @public @deprecated export class LegacyClusterClient implements ILegacyClusterClient { constructor(config: LegacyElasticsearchClientConfig, log: Logger, getAuditorFactory: () => AuditorFactory, getAuthHeaders?: GetAuthHeaders); asScoped(request?: ScopeableRequest): ILegacyScopedClusterClient; @@ -1360,7 +1385,7 @@ export interface LegacyConfig { set(config: LegacyVars): void; } -// @public (undocumented) +// @public @deprecated (undocumented) export type LegacyElasticsearchClientConfig = Pick & Pick & { pingTimeout?: ElasticsearchConfig['pingTimeout'] | ConfigOptions['pingTimeout']; requestTimeout?: ElasticsearchConfig['requestTimeout'] | ConfigOptions['requestTimeout']; @@ -1368,7 +1393,7 @@ export type LegacyElasticsearchClientConfig = Pick; }; -// @public (undocumented) +// @public export interface LegacyElasticsearchError extends Boom { // (undocumented) [code]?: string; @@ -1401,9 +1426,7 @@ export class LegacyInternals implements ILegacyInternals { export interface LegacyRequest extends Request { } -// Warning: (ae-unresolved-inheritdoc-reference) The @inheritDoc reference could not be resolved: The package "kibana" does not have an export "IScopedClusterClient" -// -// @public (undocumented) +// @public @deprecated export class LegacyScopedClusterClient implements ILegacyScopedClusterClient { constructor(internalAPICaller: LegacyAPICaller, scopedAPICaller: LegacyAPICaller, headers?: Headers | undefined, auditor?: Auditor | undefined); callAsCurrentUser(endpoint: string, clientParams?: Record, options?: LegacyCallAPIOptions): Promise; @@ -1559,10 +1582,10 @@ export interface LogRecord { export interface MetricsServiceSetup { } -// @public (undocumented) +// @public @deprecated (undocumented) export type MIGRATION_ASSISTANCE_INDEX_ACTION = 'upgrade' | 'reindex'; -// @public (undocumented) +// @public @deprecated (undocumented) export type MIGRATION_DEPRECATION_LEVEL = 'none' | 'info' | 'warning' | 'critical'; // @public @@ -1812,6 +1835,7 @@ export interface RequestHandlerContext { typeRegistry: ISavedObjectTypeRegistry; }; elasticsearch: { + client: IScopedClusterClient; legacy: { client: ILegacyScopedClusterClient; }; diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 2dae7f8f38f23a..aff749ca975342 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -275,6 +275,7 @@ export class Server { typeRegistry: coreStart.savedObjects.getTypeRegistry(), }, elasticsearch: { + client: coreStart.elasticsearch.client.asScoped(req), legacy: { client: coreStart.elasticsearch.legacy.client.asScoped(req), }, diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 7ad2f9edd33254..d35a6a5bbb9a99 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -22,6 +22,7 @@ import { CatTasksParams } from 'elasticsearch'; import { CatThreadPoolParams } from 'elasticsearch'; import { ClearScrollParams } from 'elasticsearch'; import { Client } from 'elasticsearch'; +import { ClientOptions } from '@elastic/elasticsearch'; import { ClusterAllocationExplainParams } from 'elasticsearch'; import { ClusterGetSettingsParams } from 'elasticsearch'; import { ClusterHealthParams } from 'elasticsearch'; diff --git a/tasks/config/run.js b/tasks/config/run.js index 9ac8f72d56d4af..132b51765b3edd 100644 --- a/tasks/config/run.js +++ b/tasks/config/run.js @@ -210,7 +210,7 @@ module.exports = function () { args: [ 'scripts/functional_tests', '--config', - 'test/plugin_functional/config.js', + 'test/plugin_functional/config.ts', '--bail', '--debug', ], diff --git a/test/api_integration/services/index.ts b/test/api_integration/services/index.ts index 782ea271869ba4..d024943bef792a 100644 --- a/test/api_integration/services/index.ts +++ b/test/api_integration/services/index.ts @@ -19,7 +19,6 @@ import { services as commonServices } from '../../common/services'; -// @ts-ignore not TS yet import { KibanaSupertestProvider, ElasticsearchSupertestProvider } from './supertest'; export const services = { diff --git a/test/plugin_functional/README.md b/test/plugin_functional/README.md index 476c08408658c5..075d321917c395 100644 --- a/test/plugin_functional/README.md +++ b/test/plugin_functional/README.md @@ -17,9 +17,9 @@ To run these tests during development you can use the following commands: ``` # Start the test server (can continue running) -node scripts/functional_tests_server.js --config test/plugin_functional/config.js +node scripts/functional_tests_server.js --config test/plugin_functional/config.ts # Start a test run -node scripts/functional_test_runner.js --config test/plugin_functional/config.js +node scripts/functional_test_runner.js --config test/plugin_functional/config.ts ``` ## Run Kibana with a test plugin @@ -42,7 +42,7 @@ If you wish to load up specific es archived data for your test, you can do so vi Another option, which will automatically use any specific settings the test environment may rely on, is to boot up the functional test server pointing to the plugin configuration file. ``` -node scripts/functional_tests_server --config test/plugin_functional/config.js +node scripts/functional_tests_server --config test/plugin_functional/config.ts ``` *Note:* you may still need to use the es_archiver script to boot up any required data. diff --git a/test/plugin_functional/config.js b/test/plugin_functional/config.ts similarity index 94% rename from test/plugin_functional/config.js rename to test/plugin_functional/config.ts index f51fb5e1bade4c..c611300eade103 100644 --- a/test/plugin_functional/config.js +++ b/test/plugin_functional/config.ts @@ -16,12 +16,11 @@ * specific language governing permissions and limitations * under the License. */ - +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; import path from 'path'; import fs from 'fs'; -import { services } from './services'; -export default async function ({ readConfigFile }) { +export default async function ({ readConfigFile }: FtrConfigProviderContext) { const functionalConfig = await readConfigFile(require.resolve('../functional/config')); // Find all folders in ./plugins since we treat all them as plugin folder @@ -42,7 +41,6 @@ export default async function ({ readConfigFile }) { ], services: { ...functionalConfig.get('services'), - ...services, }, pageObjects: functionalConfig.get('pageObjects'), servers: functionalConfig.get('servers'), diff --git a/test/plugin_functional/plugins/elasticsearch_client_plugin/kibana.json b/test/plugin_functional/plugins/elasticsearch_client_plugin/kibana.json new file mode 100644 index 00000000000000..a7674881e8ba02 --- /dev/null +++ b/test/plugin_functional/plugins/elasticsearch_client_plugin/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "elasticsearch_client_plugin", + "version": "0.0.1", + "kibanaVersion": "kibana", + "server": true, + "ui": false +} diff --git a/test/plugin_functional/plugins/elasticsearch_client_plugin/package.json b/test/plugin_functional/plugins/elasticsearch_client_plugin/package.json new file mode 100644 index 00000000000000..02c2955f26c863 --- /dev/null +++ b/test/plugin_functional/plugins/elasticsearch_client_plugin/package.json @@ -0,0 +1,15 @@ +{ + "name": "elasticsearch_client_plugin", + "version": "1.0.0", + "kibana": { + "version": "kibana" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.9.5" + } +} diff --git a/test/plugin_functional/services/supertest.ts b/test/plugin_functional/plugins/elasticsearch_client_plugin/server/index.ts similarity index 66% rename from test/plugin_functional/services/supertest.ts rename to test/plugin_functional/plugins/elasticsearch_client_plugin/server/index.ts index 6b7dc26248c061..3801e33a2cf3e6 100644 --- a/test/plugin_functional/services/supertest.ts +++ b/test/plugin_functional/plugins/elasticsearch_client_plugin/server/index.ts @@ -16,13 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { format as formatUrl } from 'url'; -import { FtrProviderContext } from 'test/functional/ftr_provider_context'; -import supertestAsPromised from 'supertest-as-promised'; +import { ElasticsearchClientPlugin } from './plugin'; -export function KibanaSupertestProvider({ getService }: FtrProviderContext) { - const config = getService('config'); - const kibanaServerUrl = formatUrl(config.get('servers.kibana')); - return supertestAsPromised(kibanaServerUrl); -} +export const plugin = () => new ElasticsearchClientPlugin(); diff --git a/test/plugin_functional/plugins/elasticsearch_client_plugin/server/plugin.ts b/test/plugin_functional/plugins/elasticsearch_client_plugin/server/plugin.ts new file mode 100644 index 00000000000000..5e018ca7818d39 --- /dev/null +++ b/test/plugin_functional/plugins/elasticsearch_client_plugin/server/plugin.ts @@ -0,0 +1,53 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { Plugin, CoreSetup, CoreStart, ICustomClusterClient } from 'src/core/server'; + +export class ElasticsearchClientPlugin implements Plugin { + private client?: ICustomClusterClient; + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + router.get( + { path: '/api/elasticsearch_client_plugin/context/ping', validate: false }, + async (context, req, res) => { + const { body } = await context.core.elasticsearch.client.asInternalUser.ping(); + return res.ok({ body }); + } + ); + router.get( + { path: '/api/elasticsearch_client_plugin/contract/ping', validate: false }, + async (context, req, res) => { + const [coreStart] = await core.getStartServices(); + const { body } = await coreStart.elasticsearch.client.asInternalUser.ping(); + return res.ok({ body }); + } + ); + router.get( + { path: '/api/elasticsearch_client_plugin/custom_client/ping', validate: false }, + async (context, req, res) => { + const { body } = await this.client!.asInternalUser.ping(); + return res.ok({ body }); + } + ); + } + + public start(core: CoreStart) { + this.client = core.elasticsearch.createClient('my-custom-client-test'); + } + public stop() {} +} diff --git a/test/plugin_functional/plugins/elasticsearch_client_plugin/tsconfig.json b/test/plugin_functional/plugins/elasticsearch_client_plugin/tsconfig.json new file mode 100644 index 00000000000000..d0751f31ecc5ea --- /dev/null +++ b/test/plugin_functional/plugins/elasticsearch_client_plugin/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "server/**/*.ts", + "../../../../typings/**/*" + ], + "exclude": [] +} diff --git a/test/plugin_functional/services/index.ts b/test/plugin_functional/services/index.ts index dd2b25e14fe170..453cfc5a8636ff 100644 --- a/test/plugin_functional/services/index.ts +++ b/test/plugin_functional/services/index.ts @@ -16,14 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; -import { FtrProviderContext } from 'test/functional/ftr_provider_context'; -import { KibanaSupertestProvider } from './supertest'; +import { FtrProviderContext } from '../../functional/ftr_provider_context'; -export const services = { - supertest: KibanaSupertestProvider, -}; - -export type PluginFunctionalProviderContext = FtrProviderContext & - GenericFtrProviderContext; +export type PluginFunctionalProviderContext = FtrProviderContext; diff --git a/test/plugin_functional/test_suites/core_plugins/elasticsearch_client.ts b/test/plugin_functional/test_suites/core_plugins/elasticsearch_client.ts new file mode 100644 index 00000000000000..9b9efc261126f3 --- /dev/null +++ b/test/plugin_functional/test_suites/core_plugins/elasticsearch_client.ts @@ -0,0 +1,38 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { PluginFunctionalProviderContext } from '../../services'; +import '../../plugins/core_provider_plugin/types'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + describe('elasticsearch client', () => { + it('server plugins have access to elasticsearch client via request context', async () => { + await supertest.get('/api/elasticsearch_client_plugin/context/ping').expect(200, 'true'); + }); + it('server plugins have access to elasticsearch client via core contract', async () => { + await supertest.get('/api/elasticsearch_client_plugin/contract/ping').expect(200, 'true'); + }); + it('server plugins can create a custom elasticsearch client', async () => { + await supertest + .get('/api/elasticsearch_client_plugin/custom_client/ping') + .expect(200, 'true'); + }); + }); +} diff --git a/test/plugin_functional/test_suites/core_plugins/index.ts b/test/plugin_functional/test_suites/core_plugins/index.ts index 8f54ec6c0f4cd9..99ac6dc9b84743 100644 --- a/test/plugin_functional/test_suites/core_plugins/index.ts +++ b/test/plugin_functional/test_suites/core_plugins/index.ts @@ -22,6 +22,7 @@ import { PluginFunctionalProviderContext } from '../../services'; export default function ({ loadTestFile }: PluginFunctionalProviderContext) { describe('core plugins', () => { loadTestFile(require.resolve('./applications')); + loadTestFile(require.resolve('./elasticsearch_client')); loadTestFile(require.resolve('./legacy_plugins')); loadTestFile(require.resolve('./server_plugins')); loadTestFile(require.resolve('./ui_plugins')); diff --git a/test/plugin_functional/test_suites/core_plugins/top_nav.js b/test/plugin_functional/test_suites/core_plugins/top_nav.ts similarity index 88% rename from test/plugin_functional/test_suites/core_plugins/top_nav.js rename to test/plugin_functional/test_suites/core_plugins/top_nav.ts index 5c46e3d7f76db1..6d2c6b7f85d28a 100644 --- a/test/plugin_functional/test_suites/core_plugins/top_nav.js +++ b/test/plugin_functional/test_suites/core_plugins/top_nav.ts @@ -17,8 +17,10 @@ * under the License. */ import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; -export default function ({ getService, getPageObjects }) { +// eslint-disable-next-line import/no-default-export +export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { const PageObjects = getPageObjects(['common']); const browser = getService('browser'); diff --git a/x-pack/test/plugin_api_integration/config.ts b/x-pack/test/plugin_api_integration/config.ts index 6a86bcb7176f78..b89ed6ad550a34 100644 --- a/x-pack/test/plugin_api_integration/config.ts +++ b/x-pack/test/plugin_api_integration/config.ts @@ -20,6 +20,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { testFiles: [ + require.resolve('./test_suites/platform'), require.resolve('./test_suites/task_manager'), require.resolve('./test_suites/event_log'), require.resolve('./test_suites/licensed_feature_usage'), diff --git a/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json b/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json new file mode 100644 index 00000000000000..37ec33c168e763 --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "elasticsearch_client_xpack", + "version": "1.0.0", + "kibanaVersion": "kibana", + "server": true, + "ui": false +} diff --git a/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/package.json b/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/package.json new file mode 100644 index 00000000000000..ed31989472134e --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/package.json @@ -0,0 +1,14 @@ +{ + "name": "elasticsearch_client_xpack", + "version": "1.0.0", + "kibana": { + "version": "kibana" + }, + "scripts": { + "kbn": "node ../../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.9.5" + } +} diff --git a/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/server/index.ts b/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/server/index.ts new file mode 100644 index 00000000000000..19b54a9b0326de --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/server/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ElasticsearchClientXPack } from './plugin'; + +export const plugin = () => new ElasticsearchClientXPack(); diff --git a/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/server/plugin.ts b/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/server/plugin.ts new file mode 100644 index 00000000000000..921c8f9c50860a --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/elasticsearch_client/server/plugin.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Plugin, CoreSetup } from 'kibana/server'; + +export class ElasticsearchClientXPack implements Plugin { + constructor() {} + + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + router.get( + { path: '/api/elasticsearch_client_xpack/context/user', validate: false }, + async (context, req, res) => { + const { body } = await context.core.elasticsearch.client.asCurrentUser.security.getUser(); + return res.ok({ body }); + } + ); + + router.get( + { path: '/api/elasticsearch_client_xpack/contract/user', validate: false }, + async (context, req, res) => { + const [coreStart] = await core.getStartServices(); + const { body } = await coreStart.elasticsearch.client + .asScoped(req) + .asCurrentUser.security.getUser(); + return res.ok({ body }); + } + ); + } + + public start() {} + public stop() {} +} diff --git a/x-pack/test/plugin_api_integration/test_suites/platform/elasticsearch_client.ts b/x-pack/test/plugin_api_integration/test_suites/platform/elasticsearch_client.ts new file mode 100644 index 00000000000000..c2f6e93de83c13 --- /dev/null +++ b/x-pack/test/plugin_api_integration/test_suites/platform/elasticsearch_client.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + describe('elasticsearch client', () => { + it('scopes the elasticsearch client provided via request context to user credentials', async () => { + const { body } = await supertest + .get('/api/elasticsearch_client_xpack/context/user') + .expect(200); + expect(body).not.to.be.empty(); + }); + it('scopes the elasticsearch client provided via request context to user credentials', async () => { + const { body } = await supertest + .get('/api/elasticsearch_client_xpack/contract/user') + .expect(200); + expect(body).not.to.be.empty(); + }); + }); +} diff --git a/x-pack/test/plugin_api_integration/test_suites/platform/index.ts b/x-pack/test/plugin_api_integration/test_suites/platform/index.ts new file mode 100644 index 00000000000000..3375e9ca839a58 --- /dev/null +++ b/x-pack/test/plugin_api_integration/test_suites/platform/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('platform', function taskManagerSuite() { + this.tags('ciGroup2'); + loadTestFile(require.resolve('./elasticsearch_client')); + }); +} From 15f4ead00504f0fe368e423421524325e75246e6 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Thu, 30 Jul 2020 11:20:12 -0600 Subject: [PATCH 05/11] [SIEM] Fixes broken maps link to Kibana index management (#73757) ## Summary Embarrassing simple fix If you don't have an index setup like below and click configure index patterns it takes you to `/app/kibana` when it should just be `/app` and there shouldn't be anymore hashes of `#`. Screen Shot 2020-07-29 at 5 09 10 PM --- .../index_patterns_missing_prompt.test.tsx.snap | 4 ++-- .../embeddables/index_patterns_missing_prompt.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/index_patterns_missing_prompt.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/index_patterns_missing_prompt.test.tsx.snap index 30f7df940fa994..b72b9e5c73977c 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/index_patterns_missing_prompt.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/index_patterns_missing_prompt.test.tsx.snap @@ -6,7 +6,7 @@ exports[`IndexPatternsMissingPrompt renders correctly against snapshot 1`] = ` Configure index patterns @@ -28,7 +28,7 @@ exports[`IndexPatternsMissingPrompt renders correctly against snapshot 1`] = ` beats , "defaultIndex": diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/index_patterns_missing_prompt.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/index_patterns_missing_prompt.tsx index 62ffad515f4240..b5595daa9cf474 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/index_patterns_missing_prompt.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/index_patterns_missing_prompt.tsx @@ -13,7 +13,7 @@ import * as i18n from './translations'; export const IndexPatternsMissingPromptComponent = () => { const { docLinks } = useKibana().services; - const kibanaBasePath = `${useBasePath()}/app/kibana`; + const kibanaBasePath = `${useBasePath()}/app`; return ( { values={{ defaultIndex: ( @@ -61,7 +61,7 @@ export const IndexPatternsMissingPromptComponent = () => { } actions={ Date: Thu, 30 Jul 2020 13:21:12 -0400 Subject: [PATCH 06/11] [SECURITY_SOLUTION][ENDPOINT] Fix Endpoint Host page sometimes incorrectly showing onboarding UI (#73222) * Fix incorrectly showing the onboarding message on hosts * Allow loading of the Details even if list load failed * Some test fixes * Fix missing import * refactor UT for the policy list view * Refactor more UT * remove commented out code * Fix some failing tests following cherry-picks * start work on mock host list apis * Remove extra code that came from cherry-pick * more tests fixed * All test pass * Refactoring ++ cleanup * Remove unused import --- .../common/endpoint/generate_data.ts | 105 +++++++++- .../pages/endpoint_hosts/store/action.ts | 6 + .../pages/endpoint_hosts/store/index.test.ts | 1 + .../pages/endpoint_hosts/store/middleware.ts | 42 +++- .../store/mock_host_result_list.ts | 148 +++++++++++++- .../pages/endpoint_hosts/store/reducer.ts | 7 + .../pages/endpoint_hosts/store/selectors.ts | 6 + .../management/pages/endpoint_hosts/types.ts | 2 + .../pages/endpoint_hosts/view/index.test.tsx | 192 +++++++++++------- .../pages/endpoint_hosts/view/index.tsx | 17 +- .../policy/store/policy_list/index.test.ts | 1 + .../policy/store/policy_list/middleware.ts | 1 + .../policy_list/mock_policy_result_list.ts | 40 ---- .../store/policy_list/services/ingest.test.ts | 6 +- .../store/policy_list/services/ingest.ts | 2 +- .../store/policy_list/test_mock_utils.ts | 162 ++++++--------- .../pages/policy/view/policy_details.test.tsx | 8 +- .../pages/policy/view/policy_list.test.tsx | 46 ++--- 18 files changed, 526 insertions(+), 266 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/mock_policy_result_list.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 9a92270fc9c14d..aa3f0bf287fcae 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -8,16 +8,26 @@ import seedrandom from 'seedrandom'; import { AlertEvent, EndpointEvent, + EndpointStatus, Host, HostMetadata, - OSFields, HostPolicyResponse, HostPolicyResponseActionStatus, + OSFields, PolicyData, - EndpointStatus, } from './types'; import { factory as policyFactory } from './models/policy_config'; import { parentEntityId } from './models/event'; +import { + GetAgentConfigsResponseItem, + GetPackagesResponse, +} from '../../../ingest_manager/common/types/rest_spec'; +import { + AgentConfigStatus, + EsAssetReference, + InstallationStatus, + KibanaAssetReference, +} from '../../../ingest_manager/common/types/models'; export type Event = AlertEvent | EndpointEvent; /** @@ -1062,6 +1072,97 @@ export class EndpointDocGenerator { }; } + /** + * Generate an Agent Configuration (ingest) + */ + public generateAgentConfig(): GetAgentConfigsResponseItem { + return { + id: this.seededUUIDv4(), + name: 'Agent Config', + status: AgentConfigStatus.Active, + description: 'Some description', + namespace: 'default', + monitoring_enabled: ['logs', 'metrics'], + revision: 2, + updated_at: '2020-07-22T16:36:49.196Z', + updated_by: 'elastic', + package_configs: ['852491f0-cc39-11ea-bac2-cdbf95b4b41a'], + agents: 0, + }; + } + + /** + * Generate an EPM Package for Endpoint + */ + public generateEpmPackage(): GetPackagesResponse['response'][0] { + return { + name: 'endpoint', + title: 'Elastic Endpoint', + version: '0.5.0', + description: 'This is the Elastic Endpoint package.', + type: 'solution', + download: '/epr/endpoint/endpoint-0.5.0.tar.gz', + path: '/package/endpoint/0.5.0', + icons: [ + { + src: '/package/endpoint/0.5.0/img/logo-endpoint-64-color.svg', + size: '16x16', + type: 'image/svg+xml', + }, + ], + status: 'installed' as InstallationStatus, + savedObject: { + type: 'epm-packages', + id: 'endpoint', + attributes: { + installed_kibana: [ + { id: '826759f0-7074-11ea-9bc8-6b38f4d29a16', type: 'dashboard' }, + { id: '1cfceda0-728b-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, + { id: '1e525190-7074-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, + { id: '55387750-729c-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, + { id: '92b1edc0-706a-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, + { id: 'a3a3bd10-706b-11ea-9bc8-6b38f4d29a16', type: 'map' }, + ] as KibanaAssetReference[], + installed_es: [ + { id: 'logs-endpoint.alerts', type: 'index_template' }, + { id: 'events-endpoint', type: 'index_template' }, + { id: 'logs-endpoint.events.file', type: 'index_template' }, + { id: 'logs-endpoint.events.library', type: 'index_template' }, + { id: 'metrics-endpoint.metadata', type: 'index_template' }, + { id: 'metrics-endpoint.metadata_mirror', type: 'index_template' }, + { id: 'logs-endpoint.events.network', type: 'index_template' }, + { id: 'metrics-endpoint.policy', type: 'index_template' }, + { id: 'logs-endpoint.events.process', type: 'index_template' }, + { id: 'logs-endpoint.events.registry', type: 'index_template' }, + { id: 'logs-endpoint.events.security', type: 'index_template' }, + { id: 'metrics-endpoint.telemetry', type: 'index_template' }, + ] as EsAssetReference[], + es_index_patterns: { + alerts: 'logs-endpoint.alerts-*', + events: 'events-endpoint-*', + file: 'logs-endpoint.events.file-*', + library: 'logs-endpoint.events.library-*', + metadata: 'metrics-endpoint.metadata-*', + metadata_mirror: 'metrics-endpoint.metadata_mirror-*', + network: 'logs-endpoint.events.network-*', + policy: 'metrics-endpoint.policy-*', + process: 'logs-endpoint.events.process-*', + registry: 'logs-endpoint.events.registry-*', + security: 'logs-endpoint.events.security-*', + telemetry: 'metrics-endpoint.telemetry-*', + }, + name: 'endpoint', + version: '0.5.0', + internal: false, + removable: false, + }, + references: [], + updated_at: '2020-06-24T14:41:23.098Z', + version: 'Wzc0LDFd', + }, + }; + } + /** * Generates a Host Policy response message */ diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts index 621fab2e4ee113..4a4326d5b29193 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts @@ -81,6 +81,11 @@ interface ServerReturnedHostNonExistingPolicies { payload: HostState['nonExistingPolicies']; } +interface ServerReturnedHostExistValue { + type: 'serverReturnedHostExistValue'; + payload: boolean; +} + export type HostAction = | ServerReturnedHostList | ServerFailedToReturnHostList @@ -92,6 +97,7 @@ export type HostAction = | ServerFailedToReturnPoliciesForOnboarding | UserSelectedEndpointPolicy | ServerCancelledHostListLoading + | ServerReturnedHostExistValue | ServerCancelledPolicyItemsLoading | ServerReturnedEndpointPackageInfo | ServerReturnedHostNonExistingPolicies; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts index b6e18506b61113..8ff4ad5a043b5f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts @@ -51,6 +51,7 @@ describe('HostList store concerns', () => { policyItemsLoading: false, endpointPackageInfo: undefined, nonExistingPolicies: {}, + hostsExist: true, }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index edeca5659ee38c..74bebf211258ae 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { HttpSetup } from 'kibana/public'; +import { HttpStart } from 'kibana/public'; import { HostInfo, HostResultList } from '../../../../../common/endpoint/types'; import { GetPolicyListResponse } from '../../policy/types'; import { ImmutableMiddlewareFactory } from '../../../../common/store'; @@ -28,6 +28,8 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor return ({ getState, dispatch }) => (next) => async (action) => { next(action); const state = getState(); + + // Host list if ( action.type === 'userChangedUrl' && isOnHostPage(state) && @@ -89,6 +91,19 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor // No hosts, so we should check to see if there are policies for onboarding if (hostResponse && hostResponse.hosts.length === 0) { const http = coreStart.http; + + // The original query to the list could have had an invalid param (ex. invalid page_size), + // so we check first if hosts actually do exist before pulling in data for the onboarding + // messages. + if (await doHostsExist(http)) { + return; + } + + dispatch({ + type: 'serverReturnedHostExistValue', + payload: false, + }); + try { const policyDataResponse: GetPolicyListResponse = await sendGetEndpointSpecificPackageConfigs( http, @@ -119,6 +134,8 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor }); } } + + // Host Details if (action.type === 'userChangedUrl' && hasSelectedHost(state) === true) { dispatch({ type: 'serverCancelledPolicyItemsLoading', @@ -160,7 +177,6 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor type: 'serverFailedToReturnHostList', payload: error, }); - return; } } else { dispatch({ @@ -217,7 +233,7 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor }; const getNonExistingPoliciesForHostsList = async ( - http: HttpSetup, + http: HttpStart, hosts: HostResultList['hosts'], currentNonExistingPolicies: HostState['nonExistingPolicies'] ): Promise => { @@ -274,3 +290,23 @@ const getNonExistingPoliciesForHostsList = async ( return nonExisting; }; + +const doHostsExist = async (http: HttpStart): Promise => { + try { + return ( + ( + await http.post('/api/endpoint/metadata', { + body: JSON.stringify({ + paging_properties: [{ page_index: 0 }, { page_size: 1 }], + }), + }) + ).hosts.length !== 0 + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`error while trying to check if hosts exist`); + // eslint-disable-next-line no-console + console.error(error); + } + return false; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_host_result_list.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_host_result_list.ts index 05af1ee062de6f..355c2bb5c19fc8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_host_result_list.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_host_result_list.ts @@ -4,8 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import { HostInfo, HostResultList, HostStatus } from '../../../../../common/endpoint/types'; +import { HttpStart } from 'kibana/public'; +import { + GetHostPolicyResponse, + HostInfo, + HostPolicyResponse, + HostResultList, + HostStatus, +} from '../../../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; +import { + INGEST_API_AGENT_CONFIGS, + INGEST_API_EPM_PACKAGES, + INGEST_API_PACKAGE_CONFIGS, +} from '../../policy/store/policy_list/services/ingest'; +import { + GetAgentConfigsResponse, + GetPackagesResponse, +} from '../../../../../../ingest_manager/common/types/rest_spec'; +import { GetPolicyListResponse } from '../../policy/types'; + +const generator = new EndpointDocGenerator('seed'); export const mockHostResultList: (options?: { total?: number; @@ -26,7 +45,6 @@ export const mockHostResultList: (options?: { const hosts = []; for (let index = 0; index < actualCountToReturn; index++) { - const generator = new EndpointDocGenerator('seed'); hosts.push({ metadata: generator.generateHostMetadata(), host_status: HostStatus.ERROR, @@ -45,9 +63,133 @@ export const mockHostResultList: (options?: { * returns a mocked API response for retrieving a single host metadata */ export const mockHostDetailsApiResult = (): HostInfo => { - const generator = new EndpointDocGenerator('seed'); return { metadata: generator.generateHostMetadata(), host_status: HostStatus.ERROR, }; }; + +/** + * Mock API handlers used by the Endpoint Host list. It also sets up a list of + * API handlers for Host details based on a list of Host results. + */ +const hostListApiPathHandlerMocks = ({ + hostsResults = mockHostResultList({ total: 3 }).hosts, + epmPackages = [generator.generateEpmPackage()], + endpointPackageConfigs = [], + policyResponse = generator.generatePolicyResponse(), +}: { + /** route handlers will be setup for each individual host in this array */ + hostsResults?: HostResultList['hosts']; + epmPackages?: GetPackagesResponse['response']; + endpointPackageConfigs?: GetPolicyListResponse['items']; + policyResponse?: HostPolicyResponse; +} = {}) => { + const apiHandlers = { + // endpoint package info + [INGEST_API_EPM_PACKAGES]: (): GetPackagesResponse => { + return { + response: epmPackages, + success: true, + }; + }, + + // host list + '/api/endpoint/metadata': (): HostResultList => { + return { + hosts: hostsResults, + request_page_size: 10, + request_page_index: 0, + total: hostsResults?.length || 0, + }; + }, + + // Do policies referenced in host list exist + // just returns 1 single agent config that includes all of the packageConfig IDs provided + [INGEST_API_AGENT_CONFIGS]: (): GetAgentConfigsResponse => { + const agentConfig = generator.generateAgentConfig(); + (agentConfig.package_configs as string[]).push( + ...endpointPackageConfigs.map((packageConfig) => packageConfig.id) + ); + return { + items: [agentConfig], + total: 10, + success: true, + perPage: 10, + page: 1, + }; + }, + + // Policy Response + '/api/endpoint/policy_response': (): GetHostPolicyResponse => { + return { policy_response: policyResponse }; + }, + + // List of Policies (package configs) for onboarding + [INGEST_API_PACKAGE_CONFIGS]: (): GetPolicyListResponse => { + return { + items: endpointPackageConfigs, + page: 1, + perPage: 10, + total: endpointPackageConfigs?.length, + success: true, + }; + }, + }; + + // Build a GET route handler for each host details based on the list of Hosts passed on input + if (hostsResults) { + hostsResults.forEach((host) => { + // @ts-ignore + apiHandlers[`/api/endpoint/metadata/${host.metadata.host.id}`] = () => host; + }); + } + + return apiHandlers; +}; + +/** + * Sets up mock impelementations in support of the Hosts list view + * + * @param mockedHttpService + * @param hostsResults + * @param pathHandlersOptions + */ +export const setHostListApiMockImplementation: ( + mockedHttpService: jest.Mocked, + apiResponses?: Parameters[0] +) => void = ( + mockedHttpService, + { hostsResults = mockHostResultList({ total: 3 }).hosts, ...pathHandlersOptions } = {} +) => { + const apiHandlers = hostListApiPathHandlerMocks({ ...pathHandlersOptions, hostsResults }); + + mockedHttpService.post + .mockImplementation(async (...args) => { + throw new Error(`un-expected call to http.post: ${args}`); + }) + // First time called, return list of hosts + .mockImplementationOnce(async () => { + return apiHandlers['/api/endpoint/metadata'](); + }); + + // If the hosts list results is zero, then mock the second call to `/metadata` to return + // empty list - indicating there are no hosts currently present on the system + if (!hostsResults.length) { + mockedHttpService.post.mockImplementationOnce(async () => { + return apiHandlers['/api/endpoint/metadata'](); + }); + } + + // Setup handling of GET requests + mockedHttpService.get.mockImplementation(async (...args) => { + const [path] = args; + if (typeof path === 'string') { + if (apiHandlers[path]) { + return apiHandlers[path](); + } + } + + throw new Error(`MOCK: api request does not have a mocked handler: ${path}`); + }); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts index 7f68baa4b85bdc..e54f7df4d4f75a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts @@ -29,6 +29,7 @@ export const initialHostListState: Immutable = { policyItemsLoading: false, endpointPackageInfo: undefined, nonExistingPolicies: {}, + hostsExist: true, }; /* eslint-disable-next-line complexity */ @@ -125,6 +126,11 @@ export const hostListReducer: ImmutableReducer = ( ...state, endpointPackageInfo: action.payload, }; + } else if (action.type === 'serverReturnedHostExistValue') { + return { + ...state, + hostsExist: action.payload, + }; } else if (action.type === 'userChangedUrl') { const newState: Immutable = { ...state, @@ -181,6 +187,7 @@ export const hostListReducer: ImmutableReducer = ( error: undefined, detailsError: undefined, policyResponseError: undefined, + hostsExist: true, }; } return state; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index 6e0823a920413f..ca006f21c29ac2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -203,3 +203,9 @@ export const policyResponseStatus: (state: Immutable) => string = cre export const nonExistingPolicies: ( state: Immutable ) => Immutable = (state) => state.nonExistingPolicies; + +/** + * Return boolean that indicates whether hosts exist + * @param state + */ +export const hostsExist: (state: Immutable) => boolean = (state) => state.hostsExist; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index 582a59cfd7605c..6c949e9700b9a0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -52,6 +52,8 @@ export interface HostState { endpointPackageInfo?: GetPackagesResponse['response'][0]; /** tracks the list of policies IDs used in Host metadata that may no longer exist */ nonExistingPolicies: Record; + /** Tracks whether hosts exist and helps control if onboarding should be visible */ + hostsExist: boolean; } /** diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 9d49c8705affe2..3e00a5cc33db1d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -8,18 +8,22 @@ import React from 'react'; import * as reactTestingLibrary from '@testing-library/react'; import { HostList } from './index'; -import { mockHostDetailsApiResult, mockHostResultList } from '../store/mock_host_result_list'; -import { mockPolicyResultList } from '../../policy/store/policy_list/mock_policy_result_list'; +import { + mockHostDetailsApiResult, + mockHostResultList, + setHostListApiMockImplementation, +} from '../store/mock_host_result_list'; import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { HostInfo, + HostPolicyResponse, HostPolicyResponseActionStatus, HostPolicyResponseAppliedAction, HostStatus, } from '../../../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; -import { AppAction } from '../../../../common/store/actions'; import { POLICY_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_TEXT } from './host_constants'; +import { mockPolicyResultList } from '../../policy/store/policy_list/test_mock_utils'; jest.mock('../../../../common/components/link_to'); @@ -35,6 +39,9 @@ describe('when on the hosts page', () => { const mockedContext = createAppRootMockRenderer(); ({ history, store, coreStart, middlewareSpy } = mockedContext); render = () => mockedContext.render(); + reactTestingLibrary.act(() => { + history.push('/hosts'); + }); }); it('should NOT display timeline', async () => { @@ -43,35 +50,29 @@ describe('when on the hosts page', () => { expect(timelineFlyout).toBeNull(); }); - it('should show the empty state when there are no hosts or polices', async () => { - const renderResult = render(); - // Initially, there are no hosts or policies, so we prompt to add policies first. - const table = await renderResult.findByTestId('emptyPolicyTable'); - expect(table).not.toBeNull(); - }); - - describe('when there are policies, but no hosts', () => { + describe('when there are no hosts or polices', () => { beforeEach(() => { - reactTestingLibrary.act(() => { - const hostListData = mockHostResultList({ total: 0 }); - coreStart.http.get.mockReturnValue(Promise.resolve(hostListData)); - const hostAction: AppAction = { - type: 'serverReturnedHostList', - payload: hostListData, - }; - store.dispatch(hostAction); + setHostListApiMockImplementation(coreStart.http, { + hostsResults: [], + }); + }); - jest.clearAllMocks(); + it('should show the empty state when there are no hosts or polices', async () => { + const renderResult = render(); + await reactTestingLibrary.act(async () => { + await middlewareSpy.waitForAction('serverReturnedPoliciesForOnboarding'); + }); + // Initially, there are no hosts or policies, so we prompt to add policies first. + const table = await renderResult.findByTestId('emptyPolicyTable'); + expect(table).not.toBeNull(); + }); + }); - const policyListData = mockPolicyResultList({ total: 3 }); - coreStart.http.get.mockReturnValue(Promise.resolve(policyListData)); - const policyAction: AppAction = { - type: 'serverReturnedPoliciesForOnboarding', - payload: { - policyItems: policyListData.items, - }, - }; - store.dispatch(policyAction); + describe('when there are policies, but no hosts', () => { + beforeEach(async () => { + setHostListApiMockImplementation(coreStart.http, { + hostsResults: [], + endpointPackageConfigs: mockPolicyResultList({ total: 3 }).items, }); }); afterEach(() => { @@ -80,18 +81,27 @@ describe('when on the hosts page', () => { it('should show the no hosts empty state', async () => { const renderResult = render(); + await reactTestingLibrary.act(async () => { + await middlewareSpy.waitForAction('serverReturnedPoliciesForOnboarding'); + }); const emptyHostsTable = await renderResult.findByTestId('emptyHostsTable'); expect(emptyHostsTable).not.toBeNull(); }); it('should display the onboarding steps', async () => { const renderResult = render(); + await reactTestingLibrary.act(async () => { + await middlewareSpy.waitForAction('serverReturnedPoliciesForOnboarding'); + }); const onboardingSteps = await renderResult.findByTestId('onboardingSteps'); expect(onboardingSteps).not.toBeNull(); }); it('should show policy selection', async () => { const renderResult = render(); + await reactTestingLibrary.act(async () => { + await middlewareSpy.waitForAction('serverReturnedPoliciesForOnboarding'); + }); const onboardingPolicySelect = await renderResult.findByTestId('onboardingPolicySelect'); expect(onboardingPolicySelect).not.toBeNull(); }); @@ -112,39 +122,54 @@ describe('when on the hosts page', () => { let firstPolicyID: string; beforeEach(() => { reactTestingLibrary.act(() => { - const hostListData = mockHostResultList({ total: 4 }); - firstPolicyID = hostListData.hosts[0].metadata.Endpoint.policy.applied.id; + const hostListData = mockHostResultList({ total: 4 }).hosts; + + firstPolicyID = hostListData[0].metadata.Endpoint.policy.applied.id; + [HostStatus.ERROR, HostStatus.ONLINE, HostStatus.OFFLINE, HostStatus.UNENROLLING].forEach( (status, index) => { - hostListData.hosts[index] = { - metadata: hostListData.hosts[index].metadata, + hostListData[index] = { + metadata: hostListData[index].metadata, host_status: status, }; } ); - hostListData.hosts.forEach((item, index) => { + hostListData.forEach((item, index) => { generatedPolicyStatuses[index] = item.metadata.Endpoint.policy.applied.status; }); - const action: AppAction = { - type: 'serverReturnedHostList', - payload: hostListData, - }; - store.dispatch(action); + + // Make sure that the first policy id in the host result is not set as non-existent + const ingestPackageConfigs = mockPolicyResultList({ total: 1 }).items; + ingestPackageConfigs[0].id = firstPolicyID; + + setHostListApiMockImplementation(coreStart.http, { + hostsResults: hostListData, + endpointPackageConfigs: ingestPackageConfigs, + }); }); }); it('should display rows in the table', async () => { const renderResult = render(); + await reactTestingLibrary.act(async () => { + await middlewareSpy.waitForAction('serverReturnedHostList'); + }); const rows = await renderResult.findAllByRole('row'); expect(rows).toHaveLength(5); }); it('should show total', async () => { const renderResult = render(); + await reactTestingLibrary.act(async () => { + await middlewareSpy.waitForAction('serverReturnedHostList'); + }); const total = await renderResult.findByTestId('hostListTableTotal'); expect(total.textContent).toEqual('4 Hosts'); }); it('should display correct status', async () => { const renderResult = render(); + await reactTestingLibrary.act(async () => { + await middlewareSpy.waitForAction('serverReturnedHostList'); + }); const hostStatuses = await renderResult.findAllByTestId('rowHostStatus'); expect(hostStatuses[0].textContent).toEqual('Error'); @@ -168,6 +193,9 @@ describe('when on the hosts page', () => { it('should display correct policy status', async () => { const renderResult = render(); + await reactTestingLibrary.act(async () => { + await middlewareSpy.waitForAction('serverReturnedHostList'); + }); const policyStatuses = await renderResult.findAllByTestId('rowPolicyStatus'); policyStatuses.forEach((status, index) => { @@ -184,6 +212,9 @@ describe('when on the hosts page', () => { it('should display policy name as a link', async () => { const renderResult = render(); + await reactTestingLibrary.act(async () => { + await middlewareSpy.waitForAction('serverReturnedHostList'); + }); const firstPolicyName = (await renderResult.findAllByTestId('policyNameCellLink'))[0]; expect(firstPolicyName).not.toBeNull(); expect(firstPolicyName.getAttribute('href')).toContain(`policy/${firstPolicyID}`); @@ -192,17 +223,10 @@ describe('when on the hosts page', () => { describe('when the user clicks the first hostname in the table', () => { let renderResult: reactTestingLibrary.RenderResult; beforeEach(async () => { - const hostDetailsApiResponse = mockHostDetailsApiResult(); - - coreStart.http.get.mockReturnValue(Promise.resolve(hostDetailsApiResponse)); - reactTestingLibrary.act(() => { - store.dispatch({ - type: 'serverReturnedHostDetails', - payload: hostDetailsApiResponse, - }); - }); - renderResult = render(); + await reactTestingLibrary.act(async () => { + await middlewareSpy.waitForAction('serverReturnedHostList'); + }); const hostNameLinks = await renderResult.findAllByTestId('hostnameCellLink'); if (hostNameLinks.length) { reactTestingLibrary.fireEvent.click(hostNameLinks[0]); @@ -221,9 +245,11 @@ describe('when on the hosts page', () => { describe('when there is a selected host in the url', () => { let hostDetails: HostInfo; let agentId: string; - const dispatchServerReturnedHostPolicyResponse = ( + let renderAndWaitForData: () => Promise>; + + const createPolicyResponse = ( overallStatus: HostPolicyResponseActionStatus = HostPolicyResponseActionStatus.success - ) => { + ): HostPolicyResponse => { const policyResponse = docGenerator.generatePolicyResponse(); const malwareResponseConfigurations = policyResponse.Endpoint.policy.applied.response.configurations.malware; @@ -269,21 +295,28 @@ describe('when on the hosts page', () => { policyResponse.Endpoint.policy.applied.actions.push(unknownAction); malwareResponseConfigurations.concerned_actions.push(unknownAction.name); + return policyResponse; + }; + + const dispatchServerReturnedHostPolicyResponse = ( + overallStatus: HostPolicyResponseActionStatus = HostPolicyResponseActionStatus.success + ) => { reactTestingLibrary.act(() => { store.dispatch({ type: 'serverReturnedHostPolicyResponse', payload: { - policy_response: policyResponse, + policy_response: createPolicyResponse(overallStatus), }, }); }); }; - beforeEach(() => { + beforeEach(async () => { const { host_status, metadata: { host, ...details }, } = mockHostDetailsApiResult(); + hostDetails = { host_status, metadata: { @@ -297,34 +330,37 @@ describe('when on the hosts page', () => { agentId = hostDetails.metadata.elastic.agent.id; - coreStart.http.get.mockReturnValue(Promise.resolve(hostDetails)); + const policy = docGenerator.generatePolicyPackageConfig(); + policy.id = hostDetails.metadata.Endpoint.policy.applied.id; - reactTestingLibrary.act(() => { - history.push({ - ...history.location, - search: '?selected_host=1', - }); + setHostListApiMockImplementation(coreStart.http, { + hostsResults: [hostDetails], + endpointPackageConfigs: [policy], }); + reactTestingLibrary.act(() => { - store.dispatch({ - type: 'serverReturnedHostDetails', - payload: hostDetails, - }); + history.push('/hosts?selected_host=1'); }); + + renderAndWaitForData = async () => { + const renderResult = render(); + await middlewareSpy.waitForAction('serverReturnedHostDetails'); + return renderResult; + }; }); afterEach(() => { jest.clearAllMocks(); }); - it('should show the flyout', () => { - const renderResult = render(); + it('should show the flyout', async () => { + const renderResult = await renderAndWaitForData(); return renderResult.findByTestId('hostDetailsFlyout').then((flyout) => { expect(flyout).not.toBeNull(); }); }); it('should display policy name value as a link', async () => { - const renderResult = render(); + const renderResult = await renderAndWaitForData(); const policyDetailsLink = await renderResult.findByTestId('policyDetailsValue'); expect(policyDetailsLink).not.toBeNull(); expect(policyDetailsLink.getAttribute('href')).toEqual( @@ -333,10 +369,7 @@ describe('when on the hosts page', () => { }); it('should update the URL when policy name link is clicked', async () => { - const policyItem = mockPolicyResultList({ total: 1 }).items[0]; - coreStart.http.get.mockReturnValue(Promise.resolve({ item: policyItem })); - - const renderResult = render(); + const renderResult = await renderAndWaitForData(); const policyDetailsLink = await renderResult.findByTestId('policyDetailsValue'); const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl'); reactTestingLibrary.act(() => { @@ -349,7 +382,7 @@ describe('when on the hosts page', () => { }); it('should display policy status value as a link', async () => { - const renderResult = render(); + const renderResult = await renderAndWaitForData(); const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); expect(policyStatusLink).not.toBeNull(); expect(policyStatusLink.getAttribute('href')).toEqual( @@ -358,7 +391,7 @@ describe('when on the hosts page', () => { }); it('should update the URL when policy status link is clicked', async () => { - const renderResult = render(); + const renderResult = await renderAndWaitForData(); const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl'); reactTestingLibrary.act(() => { @@ -371,7 +404,7 @@ describe('when on the hosts page', () => { }); it('should display Success overall policy status', async () => { - const renderResult = render(); + const renderResult = await renderAndWaitForData(); reactTestingLibrary.act(() => { dispatchServerReturnedHostPolicyResponse(HostPolicyResponseActionStatus.success); }); @@ -385,7 +418,7 @@ describe('when on the hosts page', () => { }); it('should display Warning overall policy status', async () => { - const renderResult = render(); + const renderResult = await renderAndWaitForData(); reactTestingLibrary.act(() => { dispatchServerReturnedHostPolicyResponse(HostPolicyResponseActionStatus.warning); }); @@ -399,7 +432,7 @@ describe('when on the hosts page', () => { }); it('should display Failed overall policy status', async () => { - const renderResult = render(); + const renderResult = await renderAndWaitForData(); reactTestingLibrary.act(() => { dispatchServerReturnedHostPolicyResponse(HostPolicyResponseActionStatus.failure); }); @@ -413,7 +446,7 @@ describe('when on the hosts page', () => { }); it('should display Unknown overall policy status', async () => { - const renderResult = render(); + const renderResult = await renderAndWaitForData(); reactTestingLibrary.act(() => { dispatchServerReturnedHostPolicyResponse('' as HostPolicyResponseActionStatus); }); @@ -428,7 +461,7 @@ describe('when on the hosts page', () => { it('should include the link to reassignment in Ingest', async () => { coreStart.application.getUrlForApp.mockReturnValue('/app/ingestManager'); - const renderResult = render(); + const renderResult = await renderAndWaitForData(); const linkToReassign = await renderResult.findByTestId('hostDetailsLinkToIngest'); expect(linkToReassign).not.toBeNull(); expect(linkToReassign.textContent).toEqual('Reassign Configuration'); @@ -440,7 +473,7 @@ describe('when on the hosts page', () => { describe('when link to reassignment in Ingest is clicked', () => { beforeEach(async () => { coreStart.application.getUrlForApp.mockReturnValue('/app/ingestManager'); - const renderResult = render(); + const renderResult = await renderAndWaitForData(); const linkToReassign = await renderResult.findByTestId('hostDetailsLinkToIngest'); reactTestingLibrary.act(() => { reactTestingLibrary.fireEvent.click(linkToReassign); @@ -461,13 +494,14 @@ describe('when on the hosts page', () => { } throw new Error(`POST to '${requestOptions.path}' does not have a mock response!`); }); - renderResult = render(); + renderResult = await renderAndWaitForData(); const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl'); reactTestingLibrary.act(() => { reactTestingLibrary.fireEvent.click(policyStatusLink); }); await userChangedUrlChecker; + await middlewareSpy.waitForAction('serverReturnedHostPolicyResponse'); reactTestingLibrary.act(() => { dispatchServerReturnedHostPolicyResponse(); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 2692f7791b7c05..58442ab417b606 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -89,6 +89,7 @@ export const HostList = () => { selectedPolicyId, policyItemsLoading, endpointPackageVersion, + hostsExist, } = useHostSelector(selector); const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); @@ -329,7 +330,7 @@ export const HostList = () => { }, [formatUrl, queryParams, search]); const renderTableOrEmptyState = useMemo(() => { - if (!loading && listData && listData.length > 0) { + if (hostsExist) { return ( { error={listError?.message} pagination={paginationSetup} onChange={onTableChange} + loading={loading} /> ); } else if (!policyItemsLoading && policyItems && policyItems.length > 0) { @@ -356,19 +358,20 @@ export const HostList = () => { ); } }, [ - listData, + loading, + hostsExist, + policyItemsLoading, policyItems, + listData, columns, - loading, + listError?.message, paginationSetup, onTableChange, - listError?.message, - handleCreatePolicyClick, handleDeployEndpointsClick, - handleSelectableOnChange, selectedPolicyId, + handleSelectableOnChange, selectionOptions, - policyItemsLoading, + handleCreatePolicyClick, ]); return ( diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/index.test.ts index 8203aae244f246..6ee6a4232f7cfb 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/index.test.ts @@ -143,6 +143,7 @@ describe('policy list store concerns', () => { isLoading: false, isDeleting: false, deleteStatus: undefined, + endpointPackageInfo: undefined, pageIndex: 0, pageSize: 10, total: 0, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/middleware.ts index b4e1da4e43da32..6fe555113617d9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/middleware.ts @@ -29,6 +29,7 @@ export const policyListMiddlewareFactory: ImmutableMiddlewareFactory GetPolicyListResponse = (options = {}) => { - const { - total = 1, - request_page_size: requestPageSize = 10, - request_page_index: requestPageIndex = 0, - } = options; - - // Skip any that are before the page we're on - const numberToSkip = requestPageSize * requestPageIndex; - - // total - numberToSkip is the count of non-skipped ones, but return no more than a pageSize, and no less than 0 - const actualCountToReturn = Math.max(Math.min(total - numberToSkip, requestPageSize), 0); - - const policies = []; - for (let index = 0; index < actualCountToReturn; index++) { - const generator = new EndpointDocGenerator('seed'); - policies.push(generator.generatePolicyPackageConfig()); - } - const mock: GetPolicyListResponse = { - items: policies, - total, - page: requestPageIndex, - perPage: requestPageSize, - success: true, - }; - return mock; -}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.test.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.test.ts index 7daa500ef18844..83cd8558306a0e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.test.ts @@ -12,7 +12,7 @@ import { } from './ingest'; import { httpServiceMock } from '../../../../../../../../../../src/core/public/mocks'; import { PACKAGE_CONFIG_SAVED_OBJECT_TYPE } from '../../../../../../../../ingest_manager/common'; -import { apiPathMockResponseProviders } from '../test_mock_utils'; +import { policyListApiPathHandlers } from '../test_mock_utils'; describe('ingest service', () => { let http: ReturnType; @@ -61,7 +61,9 @@ describe('ingest service', () => { describe('sendGetEndpointSecurityPackage()', () => { it('should query EPM with category=security', async () => { - http.get.mockReturnValue(apiPathMockResponseProviders[INGEST_API_EPM_PACKAGES]()); + http.get.mockReturnValue( + Promise.resolve(policyListApiPathHandlers()[INGEST_API_EPM_PACKAGES]()) + ); await sendGetEndpointSecurityPackage(http); expect(http.get).toHaveBeenCalledWith('/api/ingest_manager/epm/packages', { query: { category: 'security' }, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts index c6e6146f4d5e4b..266faf9eae32c9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts @@ -20,7 +20,7 @@ import { NewPolicyData } from '../../../../../../../common/endpoint/types'; const INGEST_API_ROOT = `/api/ingest_manager`; export const INGEST_API_PACKAGE_CONFIGS = `${INGEST_API_ROOT}/package_configs`; -const INGEST_API_AGENT_CONFIGS = `${INGEST_API_ROOT}/agent_configs`; +export const INGEST_API_AGENT_CONFIGS = `${INGEST_API_ROOT}/agent_configs`; const INGEST_API_FLEET = `${INGEST_API_ROOT}/fleet`; const INGEST_API_FLEET_AGENT_STATUS = `${INGEST_API_FLEET}/agent-status`; export const INGEST_API_EPM_PACKAGES = `${INGEST_API_ROOT}/epm/packages`; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/test_mock_utils.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/test_mock_utils.ts index b5c67cc2c20140..3c9d5fde9b826b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/test_mock_utils.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/test_mock_utils.ts @@ -5,122 +5,84 @@ */ import { HttpStart } from 'kibana/public'; -import { INGEST_API_PACKAGE_CONFIGS, INGEST_API_EPM_PACKAGES } from './services/ingest'; +import { INGEST_API_EPM_PACKAGES, INGEST_API_PACKAGE_CONFIGS } from './services/ingest'; import { EndpointDocGenerator } from '../../../../../../common/endpoint/generate_data'; import { GetPolicyListResponse } from '../../types'; -import { - KibanaAssetReference, - EsAssetReference, - GetPackagesResponse, - InstallationStatus, -} from '../../../../../../../ingest_manager/common'; +import { GetPackagesResponse } from '../../../../../../../ingest_manager/common'; const generator = new EndpointDocGenerator('policy-list'); -/** - * a list of API paths response mock providers - */ -export const apiPathMockResponseProviders = { - [INGEST_API_EPM_PACKAGES]: () => - Promise.resolve({ - response: [ - { - name: 'endpoint', - title: 'Elastic Endpoint', - version: '0.5.0', - description: 'This is the Elastic Endpoint package.', - type: 'solution', - download: '/epr/endpoint/endpoint-0.5.0.tar.gz', - path: '/package/endpoint/0.5.0', - icons: [ - { - src: '/package/endpoint/0.5.0/img/logo-endpoint-64-color.svg', - size: '16x16', - type: 'image/svg+xml', - }, - ], - status: 'installed' as InstallationStatus, - savedObject: { - type: 'epm-packages', - id: 'endpoint', - attributes: { - installed_kibana: [ - { id: '826759f0-7074-11ea-9bc8-6b38f4d29a16', type: 'dashboard' }, - { id: '1cfceda0-728b-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, - { id: '1e525190-7074-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, - { id: '55387750-729c-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, - { id: '92b1edc0-706a-11ea-9bc8-6b38f4d29a16', type: 'visualization' }, - { id: 'a3a3bd10-706b-11ea-9bc8-6b38f4d29a16', type: 'map' }, - ] as KibanaAssetReference[], - installed_es: [ - { id: 'logs-endpoint.alerts', type: 'index_template' }, - { id: 'events-endpoint', type: 'index_template' }, - { id: 'logs-endpoint.events.file', type: 'index_template' }, - { id: 'logs-endpoint.events.library', type: 'index_template' }, - { id: 'metrics-endpoint.metadata', type: 'index_template' }, - { id: 'metrics-endpoint.metadata_mirror', type: 'index_template' }, - { id: 'logs-endpoint.events.network', type: 'index_template' }, - { id: 'metrics-endpoint.policy', type: 'index_template' }, - { id: 'logs-endpoint.events.process', type: 'index_template' }, - { id: 'logs-endpoint.events.registry', type: 'index_template' }, - { id: 'logs-endpoint.events.security', type: 'index_template' }, - { id: 'metrics-endpoint.telemetry', type: 'index_template' }, - ] as EsAssetReference[], - es_index_patterns: { - alerts: 'logs-endpoint.alerts-*', - events: 'events-endpoint-*', - file: 'logs-endpoint.events.file-*', - library: 'logs-endpoint.events.library-*', - metadata: 'metrics-endpoint.metadata-*', - metadata_mirror: 'metrics-endpoint.metadata_mirror-*', - network: 'logs-endpoint.events.network-*', - policy: 'metrics-endpoint.policy-*', - process: 'logs-endpoint.events.process-*', - registry: 'logs-endpoint.events.registry-*', - security: 'logs-endpoint.events.security-*', - telemetry: 'metrics-endpoint.telemetry-*', - }, - name: 'endpoint', - version: '0.5.0', - internal: false, - removable: false, - }, - references: [], - updated_at: '2020-06-24T14:41:23.098Z', - version: 'Wzc0LDFd', - }, - }, - ], - success: true, - }), -}; - /** * It sets the mock implementation on the necessary http methods to support the policy list view * @param mockedHttpService - * @param responseItems + * @param totalPolicies */ export const setPolicyListApiMockImplementation = ( mockedHttpService: jest.Mocked, - responseItems: GetPolicyListResponse['items'] = [generator.generatePolicyPackageConfig()] + totalPolicies: number = 1 ): void => { - mockedHttpService.get.mockImplementation((...args) => { + const policyApiHandlers = policyListApiPathHandlers(totalPolicies); + + mockedHttpService.get.mockImplementation(async (...args) => { const [path] = args; if (typeof path === 'string') { - if (path === INGEST_API_PACKAGE_CONFIGS) { - return Promise.resolve({ - items: responseItems, - total: 10, - page: 1, - perPage: 10, - success: true, - }); - } - - if (apiPathMockResponseProviders[path]) { - return apiPathMockResponseProviders[path](); + if (policyApiHandlers[path]) { + return policyApiHandlers[path](); } } return Promise.reject(new Error(`MOCK: unknown policy list api: ${path}`)); }); }; + +/** + * Returns the response body for a call to get the list of Policies + * @param options + */ +export const mockPolicyResultList: (options?: { + total?: number; + request_page_size?: number; + request_page_index?: number; +}) => GetPolicyListResponse = (options = {}) => { + const { + total = 1, + request_page_size: requestPageSize = 10, + request_page_index: requestPageIndex = 0, + } = options; + + // Skip any that are before the page we're on + const numberToSkip = requestPageSize * requestPageIndex; + + // total - numberToSkip is the count of non-skipped ones, but return no more than a pageSize, and no less than 0 + const actualCountToReturn = Math.max(Math.min(total - numberToSkip, requestPageSize), 0); + + const policies = []; + for (let index = 0; index < actualCountToReturn; index++) { + policies.push(generator.generatePolicyPackageConfig()); + } + const mock: GetPolicyListResponse = { + items: policies, + total, + page: requestPageIndex, + perPage: requestPageSize, + success: true, + }; + return mock; +}; + +/** + * Returns an object comprised of the API path as the key along with a function that + * returns that API's result value + */ +export const policyListApiPathHandlers = (totalPolicies: number = 1) => { + return { + [INGEST_API_PACKAGE_CONFIGS]: () => { + return mockPolicyResultList({ total: totalPolicies }); + }, + [INGEST_API_EPM_PACKAGES]: (): GetPackagesResponse => { + return { + response: [generator.generateEpmPackage()], + success: true, + }; + }, + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index 03ab32dcb2b66b..c81ffb0060c888 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -11,7 +11,7 @@ import { PolicyDetails } from './policy_details'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { getPolicyDetailPath, getHostListPath } from '../../../common/routing'; -import { apiPathMockResponseProviders } from '../store/policy_list/test_mock_utils'; +import { policyListApiPathHandlers } from '../store/policy_list/test_mock_utils'; jest.mock('../../../../common/components/link_to'); @@ -80,6 +80,8 @@ describe('Policy Details', () => { policyPackageConfig = generator.generatePolicyPackageConfig(); policyPackageConfig.id = '1'; + const policyListApiHandlers = policyListApiPathHandlers(); + http.get.mockImplementation((...args) => { const [path] = args; if (typeof path === 'string') { @@ -103,9 +105,9 @@ describe('Policy Details', () => { // Get package data // Used in tests that route back to the list - if (apiPathMockResponseProviders[path]) { + if (policyListApiHandlers[path]) { asyncActions = asyncActions.then(async () => sleep()); - return apiPathMockResponseProviders[path](); + return Promise.resolve(policyListApiHandlers[path]()); } } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx index e35c97698f5cbf..97eaceff91e9c4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx @@ -5,12 +5,9 @@ */ import React from 'react'; -import * as reactTestingLibrary from '@testing-library/react'; - import { PolicyList } from './index'; -import { mockPolicyResultList } from '../store/policy_list/mock_policy_result_list'; import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; -import { AppAction } from '../../../../common/store/actions'; +import { setPolicyListApiMockImplementation } from '../store/policy_list/test_mock_utils'; jest.mock('../../../../common/components/link_to'); @@ -18,11 +15,12 @@ jest.mock('../../../../common/components/link_to'); describe.skip('when on the policies page', () => { let render: () => ReturnType; let history: AppContextTestRender['history']; - let store: AppContextTestRender['store']; + let coreStart: AppContextTestRender['coreStart']; + let middlewareSpy: AppContextTestRender['middlewareSpy']; beforeEach(() => { const mockedContext = createAppRootMockRenderer(); - ({ history, store } = mockedContext); + ({ history, coreStart, middlewareSpy } = mockedContext); render = () => mockedContext.render(); }); @@ -46,34 +44,30 @@ describe.skip('when on the policies page', () => { describe('when list data loads', () => { let firstPolicyID: string; - beforeEach(() => { - reactTestingLibrary.act(() => { - history.push('/policy'); - reactTestingLibrary.act(() => { - const policyListData = mockPolicyResultList({ total: 3 }); - firstPolicyID = policyListData.items[0].id; - const action: AppAction = { - type: 'serverReturnedPolicyListData', - payload: { - policyItems: policyListData.items, - total: policyListData.total, - pageSize: policyListData.perPage, - pageIndex: policyListData.page, - }, - }; - store.dispatch(action); - }); - }); + const renderList = async () => { + const renderResult = render(); + history.push('/policy'); + await Promise.all([ + middlewareSpy + .waitForAction('serverReturnedPolicyListData') + .then((action) => (firstPolicyID = action.payload.policyItems[0].id)), + // middlewareSpy.waitForAction('serverReturnedAgentConfigListData'), + ]); + return renderResult; + }; + + beforeEach(async () => { + setPolicyListApiMockImplementation(coreStart.http, 3); }); it('should display rows in the table', async () => { - const renderResult = render(); + const renderResult = await renderList(); const rows = await renderResult.findAllByRole('row'); expect(rows).toHaveLength(4); }); it('should display policy name value as a link', async () => { - const renderResult = render(); + const renderResult = await renderList(); const policyNameLink = (await renderResult.findAllByTestId('policyNameLink'))[0]; expect(policyNameLink).not.toBeNull(); expect(policyNameLink.getAttribute('href')).toContain(`policy/${firstPolicyID}`); From 111e8758907383baea03ccefc647fc7a2c8c6e7f Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 30 Jul 2020 10:53:44 -0700 Subject: [PATCH 07/11] [kbn/optimizer] restore x-pack bundle banner (#73767) Co-authored-by: spalger --- .../mock_repo/x-pack/baz/kibana.json | 4 +++ .../mock_repo/x-pack/baz/public/index.ts | 21 ++++++++++++ .../kbn-optimizer/src/common/bundle.test.ts | 2 ++ packages/kbn-optimizer/src/common/bundle.ts | 14 ++++++++ .../basic_optimization.test.ts.snap | 32 +++++++++++++++++ .../basic_optimization.test.ts | 34 ++++++++++++++----- .../src/optimizer/get_plugin_bundles.test.ts | 23 +++++++++++++ .../src/optimizer/get_plugin_bundles.ts | 6 ++++ .../src/worker/webpack.config.ts | 1 + src/dev/precommit_hook/casing_check_config.js | 1 + 10 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 packages/kbn-optimizer/src/__fixtures__/mock_repo/x-pack/baz/kibana.json create mode 100644 packages/kbn-optimizer/src/__fixtures__/mock_repo/x-pack/baz/public/index.ts diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/x-pack/baz/kibana.json b/packages/kbn-optimizer/src/__fixtures__/mock_repo/x-pack/baz/kibana.json new file mode 100644 index 00000000000000..10602d2e7981a7 --- /dev/null +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/x-pack/baz/kibana.json @@ -0,0 +1,4 @@ +{ + "id": "baz", + "ui": true +} diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/x-pack/baz/public/index.ts b/packages/kbn-optimizer/src/__fixtures__/mock_repo/x-pack/baz/public/index.ts new file mode 100644 index 00000000000000..7313de07be04cf --- /dev/null +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/x-pack/baz/public/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// eslint-disable-next-line no-console +console.log('plugin in an x-pack dir'); diff --git a/packages/kbn-optimizer/src/common/bundle.test.ts b/packages/kbn-optimizer/src/common/bundle.test.ts index 6197a084858548..b8f9b94379f200 100644 --- a/packages/kbn-optimizer/src/common/bundle.test.ts +++ b/packages/kbn-optimizer/src/common/bundle.test.ts @@ -48,6 +48,7 @@ it('creates cache keys', () => { "/foo/bar/c": 789, }, "spec": Object { + "banner": undefined, "contextDir": "/foo/bar", "id": "bar", "manifestPath": undefined, @@ -80,6 +81,7 @@ it('parses bundles from JSON specs', () => { expect(bundles).toMatchInlineSnapshot(` Array [ Bundle { + "banner": undefined, "cache": BundleCache { "path": "/foo/bar/target/.kbn-optimizer-cache", "state": undefined, diff --git a/packages/kbn-optimizer/src/common/bundle.ts b/packages/kbn-optimizer/src/common/bundle.ts index a354da7a21521f..25b37ace09a8fb 100644 --- a/packages/kbn-optimizer/src/common/bundle.ts +++ b/packages/kbn-optimizer/src/common/bundle.ts @@ -43,6 +43,8 @@ export interface BundleSpec { readonly sourceRoot: string; /** Absolute path to the directory where output should be written */ readonly outputDir: string; + /** Banner that should be written to all bundle JS files */ + readonly banner?: string; /** Absolute path to a kibana.json manifest file, if omitted we assume there are not dependenices */ readonly manifestPath?: string; } @@ -64,6 +66,8 @@ export class Bundle { public readonly sourceRoot: BundleSpec['sourceRoot']; /** Absolute path to the output directory for this bundle */ public readonly outputDir: BundleSpec['outputDir']; + /** Banner that should be written to all bundle JS files */ + public readonly banner: BundleSpec['banner']; /** * Absolute path to a manifest file with "requiredBundles" which will be * used to allow bundleRefs from this bundle to the exports of another bundle. @@ -81,6 +85,7 @@ export class Bundle { this.sourceRoot = spec.sourceRoot; this.outputDir = spec.outputDir; this.manifestPath = spec.manifestPath; + this.banner = spec.banner; this.cache = new BundleCache(Path.resolve(this.outputDir, '.kbn-optimizer-cache')); } @@ -112,6 +117,7 @@ export class Bundle { sourceRoot: this.sourceRoot, outputDir: this.outputDir, manifestPath: this.manifestPath, + banner: this.banner, }; } @@ -220,6 +226,13 @@ export function parseBundles(json: string) { } } + const { banner } = spec; + if (banner !== undefined) { + if (!(typeof banner === 'string')) { + throw new Error('`bundles[]` must have a string `banner` property'); + } + } + return new Bundle({ type, id, @@ -227,6 +240,7 @@ export function parseBundles(json: string) { contextDir, sourceRoot, outputDir, + banner, manifestPath, }); } diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap index 109188e163d06f..5f44d8068e694f 100644 --- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap +++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap @@ -4,6 +4,7 @@ exports[`builds expected bundles, saves bundle counts to metadata: OptimizerConf OptimizerConfig { "bundles": Array [ Bundle { + "banner": undefined, "cache": BundleCache { "path": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/target/public/.kbn-optimizer-cache, "state": undefined, @@ -19,6 +20,7 @@ OptimizerConfig { "type": "plugin", }, Bundle { + "banner": undefined, "cache": BundleCache { "path": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/target/public/.kbn-optimizer-cache, "state": undefined, @@ -33,6 +35,24 @@ OptimizerConfig { "sourceRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo, "type": "plugin", }, + Bundle { + "banner": "/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. + * Licensed under the Elastic License; you may not use this file except in compliance with the Elastic License. */ +", + "cache": BundleCache { + "path": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz/target/public/.kbn-optimizer-cache, + "state": undefined, + }, + "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz, + "id": "baz", + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz/kibana.json, + "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz/target/public, + "publicDirNames": Array [ + "public", + ], + "sourceRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo, + "type": "plugin", + }, ], "cache": true, "dist": false, @@ -60,6 +80,13 @@ OptimizerConfig { "isUiPlugin": false, "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/nested/baz/kibana.json, }, + Object { + "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz, + "extraPublicDirs": Array [], + "id": "baz", + "isUiPlugin": true, + "manifestPath": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz/kibana.json, + }, ], "profileWebpack": false, "repoRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo, @@ -73,6 +100,11 @@ OptimizerConfig { exports[`prepares assets for distribution: bar bundle 1`] = `"(function(modules){var installedModules={};function __webpack_require__(moduleId){if(installedModules[moduleId]){return installedModules[moduleId].exports}var module=installedModules[moduleId]={i:moduleId,l:false,exports:{}};modules[moduleId].call(module.exports,module,module.exports,__webpack_require__);module.l=true;return module.exports}__webpack_require__.m=modules;__webpack_require__.c=installedModules;__webpack_require__.d=function(exports,name,getter){if(!__webpack_require__.o(exports,name)){Object.defineProperty(exports,name,{enumerable:true,get:getter})}};__webpack_require__.r=function(exports){if(typeof Symbol!==\\"undefined\\"&&Symbol.toStringTag){Object.defineProperty(exports,Symbol.toStringTag,{value:\\"Module\\"})}Object.defineProperty(exports,\\"__esModule\\",{value:true})};__webpack_require__.t=function(value,mode){if(mode&1)value=__webpack_require__(value);if(mode&8)return value;if(mode&4&&typeof value===\\"object\\"&&value&&value.__esModule)return value;var ns=Object.create(null);__webpack_require__.r(ns);Object.defineProperty(ns,\\"default\\",{enumerable:true,value:value});if(mode&2&&typeof value!=\\"string\\")for(var key in value)__webpack_require__.d(ns,key,function(key){return value[key]}.bind(null,key));return ns};__webpack_require__.n=function(module){var getter=module&&module.__esModule?function getDefault(){return module[\\"default\\"]}:function getModuleExports(){return module};__webpack_require__.d(getter,\\"a\\",getter);return getter};__webpack_require__.o=function(object,property){return Object.prototype.hasOwnProperty.call(object,property)};__webpack_require__.p=\\"\\";return __webpack_require__(__webpack_require__.s=5)})([function(module,exports,__webpack_require__){\\"use strict\\";var isOldIE=function isOldIE(){var memo;return function memorize(){if(typeof memo===\\"undefined\\"){memo=Boolean(window&&document&&document.all&&!window.atob)}return memo}}();var getTarget=function getTarget(){var memo={};return function memorize(target){if(typeof memo[target]===\\"undefined\\"){var styleTarget=document.querySelector(target);if(window.HTMLIFrameElement&&styleTarget instanceof window.HTMLIFrameElement){try{styleTarget=styleTarget.contentDocument.head}catch(e){styleTarget=null}}memo[target]=styleTarget}return memo[target]}}();var stylesInDom=[];function getIndexByIdentifier(identifier){var result=-1;for(var i=0;i { it('builds expected bundles, saves bundle counts to metadata', async () => { const config = OptimizerConfig.create({ repoRoot: MOCK_REPO_DIR, - pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins')], + pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins'), Path.resolve(MOCK_REPO_DIR, 'x-pack')], maxWorkerCount: 1, dist: false, }); @@ -100,7 +100,7 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { (msg.event?.type === 'bundle cached' || msg.event?.type === 'bundle not cached') && msg.state.phase === 'initializing' ); - assert('produce two bundle cache events while initializing', bundleCacheStates.length === 2); + assert('produce three bundle cache events while initializing', bundleCacheStates.length === 3); const initializedStates = msgs.filter((msg) => msg.state.phase === 'initialized'); assert('produce at least one initialized event', initializedStates.length >= 1); @@ -110,17 +110,17 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { const runningStates = msgs.filter((msg) => msg.state.phase === 'running'); assert( - 'produce two or three "running" states', - runningStates.length === 2 || runningStates.length === 3 + 'produce three to five "running" states', + runningStates.length >= 3 && runningStates.length <= 5 ); const bundleNotCachedEvents = msgs.filter((msg) => msg.event?.type === 'bundle not cached'); - assert('produce two "bundle not cached" events', bundleNotCachedEvents.length === 2); + assert('produce three "bundle not cached" events', bundleNotCachedEvents.length === 3); const successStates = msgs.filter((msg) => msg.state.phase === 'success'); assert( - 'produce one or two "compiler success" states', - successStates.length === 1 || successStates.length === 2 + 'produce one to three "compiler success" states', + successStates.length >= 1 && successStates.length <= 3 ); const otherStates = msgs.filter( @@ -175,12 +175,26 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { /packages/kbn-ui-shared-deps/public_path_module_creator.js, ] `); + + const baz = config.bundles.find((b) => b.id === 'baz')!; + expect(baz).toBeTruthy(); + baz.cache.refresh(); + expect(baz.cache.getModuleCount()).toBe(3); + + expect(baz.cache.getReferencedFiles()).toMatchInlineSnapshot(` + Array [ + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz/kibana.json, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz/public/index.ts, + /packages/kbn-optimizer/target/worker/entry_point_creator.js, + /packages/kbn-ui-shared-deps/public_path_module_creator.js, + ] + `); }); it('uses cache on second run and exist cleanly', async () => { const config = OptimizerConfig.create({ repoRoot: MOCK_REPO_DIR, - pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins')], + pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins'), Path.resolve(MOCK_REPO_DIR, 'x-pack')], maxWorkerCount: 1, dist: false, }); @@ -202,6 +216,7 @@ it('uses cache on second run and exist cleanly', async () => { "initializing", "initializing", "initializing", + "initializing", "initialized", "success", ] @@ -211,7 +226,7 @@ it('uses cache on second run and exist cleanly', async () => { it('prepares assets for distribution', async () => { const config = OptimizerConfig.create({ repoRoot: MOCK_REPO_DIR, - pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins')], + pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins'), Path.resolve(MOCK_REPO_DIR, 'x-pack')], maxWorkerCount: 1, dist: true, }); @@ -224,6 +239,7 @@ it('prepares assets for distribution', async () => { 'foo async bundle' ); expectFileMatchesSnapshotWithCompression('plugins/bar/target/public/bar.plugin.js', 'bar bundle'); + expectFileMatchesSnapshotWithCompression('x-pack/baz/target/public/baz.plugin.js', 'baz bundle'); }); /** diff --git a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts index a70cfc759dd55b..a823f66cf767b8 100644 --- a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts +++ b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.test.ts @@ -48,12 +48,20 @@ it('returns a bundle for core and each plugin', () => { extraPublicDirs: [], manifestPath: '/outside/of/repo/plugins/baz/kibana.json', }, + { + directory: '/repo/x-pack/plugins/box', + id: 'box', + isUiPlugin: true, + extraPublicDirs: [], + manifestPath: '/repo/x-pack/plugins/box/kibana.json', + }, ], '/repo' ).map((b) => b.toSpec()) ).toMatchInlineSnapshot(` Array [ Object { + "banner": undefined, "contextDir": /plugins/foo, "id": "foo", "manifestPath": /plugins/foo/kibana.json, @@ -65,6 +73,7 @@ it('returns a bundle for core and each plugin', () => { "type": "plugin", }, Object { + "banner": undefined, "contextDir": "/outside/of/repo/plugins/baz", "id": "baz", "manifestPath": "/outside/of/repo/plugins/baz/kibana.json", @@ -75,6 +84,20 @@ it('returns a bundle for core and each plugin', () => { "sourceRoot": , "type": "plugin", }, + Object { + "banner": "/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. + * Licensed under the Elastic License; you may not use this file except in compliance with the Elastic License. */ + ", + "contextDir": /x-pack/plugins/box, + "id": "box", + "manifestPath": /x-pack/plugins/box/kibana.json, + "outputDir": /x-pack/plugins/box/target/public, + "publicDirNames": Array [ + "public", + ], + "sourceRoot": , + "type": "plugin", + }, ] `); }); diff --git a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts index 04ab992addeec1..9350b9464242af 100644 --- a/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts +++ b/packages/kbn-optimizer/src/optimizer/get_plugin_bundles.ts @@ -24,6 +24,8 @@ import { Bundle } from '../common'; import { KibanaPlatformPlugin } from './kibana_platform_plugins'; export function getPluginBundles(plugins: KibanaPlatformPlugin[], repoRoot: string) { + const xpackDirSlash = Path.resolve(repoRoot, 'x-pack') + Path.sep; + return plugins .filter((p) => p.isUiPlugin) .map( @@ -36,6 +38,10 @@ export function getPluginBundles(plugins: KibanaPlatformPlugin[], repoRoot: stri contextDir: p.directory, outputDir: Path.resolve(p.directory, 'target/public'), manifestPath: p.manifestPath, + banner: p.directory.startsWith(xpackDirSlash) + ? `/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements.\n` + + ` * Licensed under the Elastic License; you may not use this file except in compliance with the Elastic License. */\n` + : undefined, }) ); } diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index 271ad49aee351c..3d62ed16368696 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -72,6 +72,7 @@ export function getWebpackConfig(bundle: Bundle, bundleRefs: BundleRefs, worker: new CleanWebpackPlugin(), new DisallowedSyntaxPlugin(), new BundleRefsPlugin(bundle, bundleRefs), + ...(bundle.banner ? [new webpack.BannerPlugin({ banner: bundle.banner, raw: true })] : []), ], module: { diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 864bf7515053c6..404ad671746815 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -105,6 +105,7 @@ export const IGNORE_DIRECTORY_GLOBS = [ 'test/functional/fixtures/es_archiver/visualize_source-filters', 'packages/kbn-pm/src/utils/__fixtures__/*', 'x-pack/dev-tools', + 'packages/kbn-optimizer/src/__fixtures__/mock_repo/x-pack', ]; /** From 1e067b1608adc89cf647f47a24bcecde2d3e99fe Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Thu, 30 Jul 2020 14:01:29 -0400 Subject: [PATCH 08/11] [Security Solution][Resolver] Adding resolver backend docs (#73726) * Adding resolver backend docs * Adding more clarity around ancestry array limit Co-authored-by: Elastic Machine --- .../common/endpoint/types.ts | 2 + .../endpoint/routes/resolver/docs/README.md | 216 ++++++++++++++++++ .../resolver/docs/resolver_tree_ancestry.png | Bin 0 -> 19926 bytes .../docs/resolver_tree_children_loop.png | Bin 0 -> 40224 bytes .../resolver_tree_children_pagination.png | Bin 0 -> 32477 bytes ...er_tree_children_pagination_with_after.png | Bin 0 -> 32398 bytes .../docs/resolver_tree_children_simple.png | Bin 0 -> 22889 bytes .../resolver/utils/ancestry_query_handler.ts | 24 +- .../endpoint/routes/resolver/utils/tree.ts | 33 +-- 9 files changed, 242 insertions(+), 33 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/resolver/docs/README.md create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/resolver/docs/resolver_tree_ancestry.png create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/resolver/docs/resolver_tree_children_loop.png create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/resolver/docs/resolver_tree_children_pagination.png create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/resolver/docs/resolver_tree_children_pagination_with_after.png create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/resolver/docs/resolver_tree_children_simple.png diff --git a/x-pack/plugins/security_solution/common/endpoint/types.ts b/x-pack/plugins/security_solution/common/endpoint/types.ts index a982f9ffe8f21f..1c24e1abe5a57e 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types.ts @@ -104,6 +104,8 @@ export interface ResolverChildNode extends ResolverLifecycleNode { * * string: Indicates this is a leaf node and it can be used to continue querying for additional descendants * using this node's entity_id + * + * For more information see the resolver docs on pagination [here](../../server/endpoint/routes/resolver/docs/README.md#L129) */ nextChild?: string | null; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/docs/README.md b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/docs/README.md new file mode 100644 index 00000000000000..1c0692db344c4f --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/docs/README.md @@ -0,0 +1,216 @@ +# Resolver Backend + +This readme will describe the backend implementation for resolver. + +## Ancestry Array + +The ancestry array is an array of entity_ids. This array is included with each event sent by the elastic endpoint +and defines the ancestors of a particular process. The array is formatted such that [0] of the array contains the direct +parent of the process. [1] of the array contains the grandparent of the process. For example if Process A spawned process +B which spawned process C. Process C's array would be [B,A]. + +The presence of the ancestry array makes querying ancestors and children for a process more efficient. + +## Ancestry Array Limit + +The ancestry array is currently limited to 20 values. The exact limit should not be relied on though. + +## Ancestors + +To query for ancestors of a process leveraging the ancestry array, we first retrieve the lifecycle events for the event_id +passed in. Once we have the origin node we can check to see if the document has the `process.Ext.ancestry` array. If +it does we can perform a search for the values in the array. This will retrieve all the ancestors for the process of interest +up to the limit of the ancestry array. Since the array is capped at 20, if the request is asking for more than 20 +ancestors we will have to examine the most distant ancestor that has been retrieved and use its ancestry array to retrieve +the next set of results to fulfill the request. + +### Pagination + +After the backend gathers the results for an ancestry query, it will set a pagination cursor depending on the results from ES. + +If the number of ancestors we have gathered is equal to the size in the request we don't know if ES has more results or not. So we will set `nextAncestor` to the entity_id of the most distant ancestor retrieved. + +If the request asked for 10 and we only found 8 from ES, we know for sure that there aren't anymore results. In this case we will set `nextAncestor` to `null`. + +### Code + +The code for handling the ancestor logic is in [here](../utils/ancestry_query_handler.ts) + +### Ancestors Multiple Queries Example + +![alt text](./resolver_tree_ancestry.png 'Retrieve ancestors') + +For this example let's assume that the _ancestry array limit_ is 2. The process of interest is A (the entity_id of a node is the character in the circle). Process A has an ancestry array of `[3,2]`, its parent has an ancestry array of `[2,1]` etc. Here is the execution of a request for 3 ancestors for entity_id A. + +**Request:** `GET /resolver/A/ancestry?ancestors=3` + +1. Retrieve lifecycle events for entity_id `A` +2. Retrieve `A`'s start event's ancestry array + 1. In the event that the node of interest does not have an ancestry array, the entity id of it's parent will be used, essentially an ancestry array of length 1, [3] in the example here +3. `A`'s ancestry array is `[3,2]`, query for the lifecycle events for processes with `entity_id` 3 or 2 +4. Check to see if we have retrieved enough ancestors to fulfill the request (we have not, we only received 2 nodes of the 3 that were requested) +5. We haven't so use the most distant ancestor in our result set (process 2) +6. Use process 2's ancestry array to query for the next set of results to fulfill the request +7. Process 2's ancestry array is `[1]` so repeat the process in steps 3-4 and retrieve process with entity_id 1. This fulfills the request so we can return the results for the lifecycle events of A, 3, 2, and 1. + +If process 2 had an ancestry array of `[1,0]` we know that we only need 1 more process to fulfill the request so we can truncate the array to `[1]` instead of searching for all the entries in the array. + +More generically: In the event where our request stops at the x (non-final) position in an ancestry array, we won't search all items in the array, just those up to the x position. The next-cursor will be set to the last ancestor received since there might be more data. + +The `nextAncestor` cursor will be set to `1` in this scenario because we retrieved all 3 ancestors from ES but we don't know if ES has anymore. + +## Descendants + +We can also leverage the ancestry array to query for the descendants of a process. The basic query for the descendants of a process is: _find all processes where their ancestry array contains a particular entity_id_. The results of this query will be sorted in ascending order by the timestamp field. I will try to outline a couple different scenarios for retrieving descendants using the ancestry array below. + +### Start events vs all lifecycle events + +There are two parts to querying for descendant process nodes. When a request comes in for 7 process nodes we need to communicate to ES that we want all of the lifecycle nodes for 7 processes. We could use a query that retrieves all lifecycle events (start, end, etc) but the issue with this is that we need to indicate a `size` in our ES query. If we set the `size` to 7, we will only get 7 lifecycle events. These events could be start, end, or already_running events. It doesn't guarantee that we get all of the lifecycle events for 7 process nodes. + +Instead we can first query for 7 start events, which guarantees that we will have 7 unique process descendants and then we can gather all those entity_ids and do another query for all the lifecycle events for those 7 processes. The downside here is that you have to do two queries to retrieve all the lifecycle events. Optimizations can be made for the first query for the start events by reducing the `_source` that ES returns to only include the `entity_id` and `ancestry`. This will reduce the amount of data that ES has to send back and speed up the query. + +### Scenario Background + +In the scenarios below let's assume the _ancestry array limit_ is 2. The times next to the nodes are the time the node was spawned. The value in red indicates that the process terminated at the time in red. + +Let's also ignore the fact that retrieving the lifecycle events for a descendant actually takes two queries. Let's assume that it's taken care of, and when we say "query for lifecycle events" we get all the lifecycle events back for the descendants using the algorithm described in the [previous section](#start-events-vs-all-lifecycle-events) + +### Simple Scenario + +For reference the time based order of the being nodes being spawned in this scenario is: A -> B -> C -> E -> F -> G -> H. + +![alt text](./resolver_tree_children_simple.png 'Descendants Simple Scenario') + +**Request:** `GET /resolver/A/children?children=6` + +For this scenario we will retrieve all the lifecycle events for 6 descendants of the process with entity_id `A`. As shown in the diagram above ES has 6 descendants for A so the response to this request will be: `[B, C, E, F, G, H]` because the results are sorted in ascending ordering based on the `timestamp` field which is when the process was started. + +### Looping Scenario + +For reference the time based order of the being nodes being spawned in this scenario is: A -> B -> C -> D -> E -> F -> G -> J -> K -> H. + +![alt text](./resolver_tree_children_loop.png 'Descendants Looping Scenario') + +**Request:** `GET /resolver/A/children?children=9` + +In this scenario the request is for more descendants than can be retrieved using a single querying with the entity_id `A`. This is because the ancestry array for the descendants in the red section do not have `A` in their ancestry array. So when we query for all process nodes that have `A` in their ancestry array we won't receive D, J, or K. + +Like in the previous scenario, for the first query we will receive `[B, C, E, F, G, H]`. What we want to do next is use a subset of that response that will get us 3 more descendants to fulfill the request for a total of 9. + +We _could_ use `B` and `G` to do this (mostly `B`) but the problem is that when we query for descendants that have `B` or `G` in their ancestry array we will get back some duplicates that we have already received before. For example if we use `B` and `G` we'd get `[C, D, E, F, J, K, H]` but this isn't efficient because we have already received `[E, F, G, H]` from the previous query. + +What we want to do is use the most distant descendants from `A` to make the next query to retrieve the last 3 process nodes to fulfill the request. Those would be `[C, E, F, H]`. So our next query will be: _find all process nodes where their ancestry array contains C or E or F or H_. This query can be limited to a size of 3 so that we will only receive `[D, J, K]`. + +We have now received all the nodes for the request and we can return the results as `[B, C, E, F, G, H, D, J, K]`. + +### Important Caveats + +#### Ordering + +In the previous example the final results are not sorted based on timestamp in ascending order. This is because we had to perform multiple queries to retrieve all the results. The backend will not return the results in sorted order. + +#### Tie breaks on timestamp + +In the previous example we saw that J and K had the same timestamp of `12:13 pm`. The reason they were returned in the order `[J, K]` is because the `event.id` field is used to break ties like this. The `event.id` field is unique for a particular event and an increasing value per ECS's guidelines. Therefore J comes before K because it has an `event.id` of 1 vs 2. + +#### Finding the most distant descendants + +In the previous scenario we saw that we needed to use the most distant descendants from a particular node. To determine if a node is a most distant descendant we can use the ancestry array. Nodes C, E, F, and H all have `A` as their last entry in the ancestry array. This indicates that they are a distant descendant that should be used in the next query. There's one problem with this approach. In a mostly impossible scenario where the node of interest (A) does not have any ancestors, its direct children will also have `A` as the last entry in their ancestry array. + +This edge case will likely never be encountered but we'll try to solve it anyway. To get around this as we iterate over the results from our first query (`[B, C, E, F, G, H]`) we can bucket the ones that have `A` as the last entry in their ancestry array. We bucket the results based on the length of their ancestry array (basically a `Map>`). So after bucketing our results will look like: + +```javascript +{ + 1: [B, G] + 2: [C, E, F, H] +} +``` + +While we are iterating we also keep track of the largest ancestry array that we have seen. In our scenario that will be a size of 2. Then to determine the distant descendants we simply get the nodes that had the largest ancestry array length. In this scenario that'd be `[C, E, F, H]`. + +### Handling Pagination + +#### Pagination Cursor Values + +There are 3 possible states for the pagination cursor for a child node and 2 possible states for the pagination cursor for the node of interest (the node that we are using in the API request). + +Potential cursors for the node of interest: +**a string cursor:** a cursor that can be used to skip the previous set of results. The cursor is a base64 version of a json object with `event.id` and `timestamp` of the last process's start event that was received. +**null:** indicates that no more results can be received using this process's entity_id + +Potential cursors for descendants of the node of interest (these apply to the results of a request): +**a string cursor:** a cursor that can be used to skip the previous set of results. The cursor is a base64 version of a json object with `event.id` and `timestamp` of the last process's start event that was received. This cursor should be used in conjunction with using this process's entity_id for a query. +**undefined:** the node may contain additional children, but we are not aware. To find out, perform additional queries on the node of interest that original returned these results or move down the tree to a descendant of this node to query for more descendants. +**null:** We have found all possible direct children for this node. There may be more descendants but not direct children for this node. + +#### Pagination Examples + +For reference the time based order of the being nodes being spawned in this scenario is: A -> B -> C -> D -> G -> F -> H -> E -> J -> K. + +![alt text](./resolver_tree_children_pagination.png 'Descendants Pagination Scenario') + +Handling pagination for the children API in a little tricky. Let's consider this scenario: + +**Request:** `GET /resolver/A/children?children=3` + +Let's use the diagram above to show the relationship between processes and the data in ES. The response for the request for 3 children is `[B, C, G]`. More process nodes exist in ES so it would be helpful to indicate in the response that there is more data and a way to skip `[B, C, G]` to get the next set of data. A cursor can be set on the response for `A` to point to the last process node in the response which can be sent in another request to retrieve the next set of data. This cursor will contain information from `G` because it has the latest timestamp (in ascending order). + +If another request was made using the returned cursor like the following: + +**Request:** `GET /resolver/A/children?children=5&after=` + +For reference the time based order of the being nodes being spawned in this scenario is: A -> B -> C -> D -> G -> F -> H -> E -> J -> K (the same as above). + +![alt text](./resolver_tree_children_pagination_with_after.png 'Descendants Pagination Scenario Part 2') + +For this request we will do a query for _find all process nodes where their ancestry array has entity_id `A` and use the cursor to skip old results_. The response for this request is `[F, H, E, K]`. The request actually asked for 5 nodes but there was only 4 in ES so only 4 were returned. + +The odd thing about this response is that it did not receive D and J. The problem is that the backend does not have any concept of C in this second request because it was received in the previous one. It will be skipped based on the pagination cursor returned previously. + +This example highlights a scenario where it is not easy for the backend to go back and continue to get the descendants for C because of the limitation of the ancestry array. + +#### Pagination cursor for descendant nodes + +Let's go back to the first request where we got `[B, C, G]`. How could we go about getting the rest of the children for `B`? We have two ways of solving this. First we could determine what the last descendant we had received of `B` and use that as the cursor when returning all the results for this request. That is actually kind of difficult. + +For reference the time based order of the being nodes being spawned in this scenario is: A -> B -> C -> D -> G -> F -> H -> E -> J -> K (the same as above). + +![alt text](./resolver_tree_children_pagination.png 'Descendants Pagination Scenario') + +Let's imagine for a moment that the _ancestry array limit_ is 3 instead of 2. Taking our previous request we would instead get `[B, C, D]` because D started before G. In this case the last descendant for `B` is actually `D` and not `C`. This gets complicated because we'd have to keep track of which descendant was the last (time wise) one for each intermediate process node. In this example we'd need to find the last descendant for both `B` and `C`. We'd have to track the descendants for each process node and build a map to quickly be able to retrieve the last descendant. + +Instead of doing that we could also continue to get the immediate children of `B` by doing another request for `A` like was shown in previous example when using the after cursor. This would guarantee that all children (first level descendants of a node) had been retrieved. + +For reference the time based order of the being nodes being spawned in this scenario is: A -> B -> C -> D -> G -> F -> H -> E -> J -> K (the same as above). + +![alt text](./resolver_tree_children_pagination_with_after.png 'Descendants Pagination Scenario Part 2') + +To get to the response shown in the diagram above (the blue nodes is the response) a request was made for 3 nodes which returned `[B, C, D]` and then another request was made using the returned cursor for `A` to get an additional 4 nodes `[F, H, E, K]`. Let's assume that the request looked like: + +**Request:** `GET /resolver/A/children?children=5&after=` + +So 5 nodes were actually asked for. After `[F, H, E]` are returned during the first query to ES, we will use the most distant children (also `[F, H, E]`) and make another request for any nodes that have F or H or E in their ancestry array. Only a single node satisfies that query which returns `K`. Therefore we can know with certainty that E, F, and H have no more children because ES only returned K instead of K and one more node (since we requested a total of 5). With this knowledge we can mark A, E, F, H's pagination cursors in a way to communicate that they have no more descendants. + +The way the backend communicates this is by marking the cursor as null. + +If the request was actually for only 4 children like: + +**Request:** `GET /resolver/A/children?children=4&after=` + +Then we wouldn't know for sure whether ES had more results than K. But what we can know is that `A` does not have any more descendants that we can retrieve in a single query using its ancestry array. We have received all nodes where `A` is in their ancestry array when we made the second query for nodes E, F, and H and received `K`. Therefore at the moment when we received `[F, H, E]` we can mark `A`'s cursor as null. + +When we make the next query for any nodes that have F or H or E in their ancestry array and get back K, this satisfies our size of only needing one more node (4 total). At this point we don't know for sure if E, F, or H have more descendants. Since we don't know we will mark E, F, and H's cursors to point to K. + +#### Undefined Pagination + +For reference the time based order of the being nodes being spawned in this scenario is: A -> B -> C -> D -> E -> F -> G -> J -> K -> H. (Not the same as above). + +For this scenario let's assume ES has the data in the diagram below. Let's say the request looks like: + +**Request:** `GET /resolver/A/children?children=6` + +![alt text](./resolver_tree_children_loop.png 'Descendants Looping Scenario') + +The result for this request will be `[B, C, E, F, G, H]`. Since the request was looking for 6 nodes and we got that amount the cursor for `A` will be set to the last one: `H`. The cursors for the intermediate nodes `B` and `G` will be undefined. This is because we don't know if `B` or `G` have more children but `A` can be used to determine that. We also don't know if C, E, F, or H have more descendants so their cursor will also be marked as undefined. If we wanted to know if C had more descendants we can simply issue a new request like `GET /resolver/C/children` to get its descendants and we won't receive and duplicates because we never received D, J or K. + +If we want to know if `B` has more children we can issue another request using the cursor set for `A`. diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/docs/resolver_tree_ancestry.png b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/docs/resolver_tree_ancestry.png new file mode 100644 index 0000000000000000000000000000000000000000..a2636c7cd38cb515849ede721f4e4d24a851241a GIT binary patch literal 19926 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
99kI%2SH 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

1~>f(gVdq^NZINKai89#6pt6sG< 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 literal 0 HcmV?d00001 diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/docs/resolver_tree_children_simple.png b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/docs/resolver_tree_children_simple.png new file mode 100644 index 0000000000000000000000000000000000000000..f05963c0f0b677ab0882628de635bff7714a85c1 GIT binary patch literal 22889 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%0Dcg{&6(YS?=)aT0-r6RgHFfT*uf(W7+h$d9Q`N+ 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 { // bucket the start and end events together for a single node const ancestryNodes = this.toMapOfNodes(results); - // the order of this array is going to be weird, it will look like this - // [furthest grandparent...closer grandparent, next recursive call furthest grandparent...closer grandparent] + /** + * This array (this.ancestry.ancestors) is the accumulated ancestors of the node of interest. This array is different + * from the ancestry array of a specific document. The order of this array is going to be weird, it will look like this + * [most distant ancestor...closer ancestor, next recursive call most distant ancestor...closer ancestor] + * + * Here is an example of why this happens + * Consider the following tree: + * A -> B -> C -> D -> E -> Origin + * Where A was spawn before B, which was before C, etc + * + * Let's assume the ancestry array limit is 2 so Origin's array would be: [E, D] + * E's ancestry array would be: [D, C] etc + * + * If a request comes in to retrieve all the ancestors in this tree, the accumulate results will be: + * [D, E, B, C, A] + * + * The first iteration would retrieve D and E in that order because they are sorted in ascending order by timestamp. + * The next iteration would get the ancestors of D (since that's the most distant ancestor from Origin) which are + * [B, C] + * The next iteration would get the ancestors of B which is A + * Hence: [D, E, B, C, A] + */ this.ancestry.ancestors.push(...ancestryNodes.values()); this.ancestry.nextAncestor = parentEntityId(results[0]) || null; this.levels = this.levels - ancestryNodes.size; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/tree.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/tree.ts index 9e47f4eb94485b..3f941851a4143c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/tree.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/tree.ts @@ -30,38 +30,9 @@ export interface Options { } /** - * This class aids in constructing a tree of process events. It works in the following way: + * This class aids in constructing a tree of process events. * - * 1. We construct a tree structure starting with the root node for the event we're requesting. - * 2. We leverage the ability to pass hashes and arrays by reference to construct a fast cache of - * process identifiers that updates the tree structure as we push values into the cache. - * - * When we query a single level of results for child process events we have a flattened, sorted result - * list that we need to add into a constructed tree. We also need to signal in an API response whether - * or not there are more child processes events that we have not yet retrieved, and, if so, for what parent - * process. So, at the end of our tree construction we have a relational layout of the events with no - * pagination information for the given parent nodes. In order to actually construct both the tree and - * insert the pagination information we basically do the following: - * - * 1. Using a terms aggregation query, we return an approximate roll-up of the number of child process - * "creation" events, this gives us an estimation of the number of associated children per parent - * 2. We feed these child process creation event "unique identifiers" (basically a process.entity_id) - * into a second query to get the current state of the process via its "lifecycle" events. - * 3. We construct the tree above with the "lifecycle" events. - * 4. Using the terms query results, we mark each non-leaf node with the number of expected children, if our - * tree has less children than expected, we create a pagination cursor to indicate "we have a truncated set - * of values". - * 5. We mark each leaf node (the last level of the tree we're constructing) with a "null" for the expected - * number of children to indicate "we have not yet attempted to get any children". - * - * Following this scheme, we use exactly 2 queries per level of children that we return--one for the pagination - * and one for the lifecycle events of the processes. The downside to this is that we need to dynamically expand - * the number of documents we can retrieve per level due to the exponential fanout of child processes, - * what this means is that noisy neighbors for a given level may hide other child process events that occur later - * temporally in the same level--so, while a heavily forking process might get shown, maybe the actually malicious - * event doesn't show up in the tree at the beginning. - * - * This Tree's root/origin could be in the middle of the tree. The origin corresponds to the id passed in when this + * This Tree's root/origin will likely be in the middle of the tree. The origin corresponds to the id passed in when this * Tree object is constructed. The tree can have ancestors and children coming from the origin. */ export class Tree { From 827e91c4474e8790374417d30b433296f909b110 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 30 Jul 2020 11:16:49 -0700 Subject: [PATCH 09/11] move and unify postcss config into `@kbn/optimizer` (#73633) Co-authored-by: spalger --- .eslintrc.js | 1 + package.json | 2 +- packages/kbn-optimizer/package.json | 1 + .../{src/worker => }/postcss.config.js | 0 .../basic_optimization.test.ts | 2 +- .../src/worker/webpack.config.ts | 2 +- .../kbn-storybook/lib/webpack.dll.config.js | 2 +- .../storybook_config/webpack.config.js | 2 +- packages/kbn-ui-framework/Gruntfile.js | 2 +- .../doc_site/postcss.config.js | 22 ------------------- packages/kbn-ui-framework/package.json | 4 ++-- packages/kbn-ui-shared-deps/package.json | 1 + src/optimize/base_optimizer.js | 2 +- x-pack/package.json | 7 +++++- .../shareable_runtime/postcss.config.js | 1 - .../shareable_runtime/webpack.config.js | 2 +- .../canvas/storybook/webpack.config.js | 8 +++++-- .../canvas/storybook/webpack.dll.config.js | 2 +- 18 files changed, 26 insertions(+), 37 deletions(-) rename packages/kbn-optimizer/{src/worker => }/postcss.config.js (100%) delete mode 100644 packages/kbn-ui-framework/doc_site/postcss.config.js diff --git a/.eslintrc.js b/.eslintrc.js index c9f9d96f9ddaee..b3d29c98664114 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -529,6 +529,7 @@ module.exports = { 'x-pack/test_utils/**/*', 'x-pack/gulpfile.js', 'x-pack/plugins/apm/public/utils/testHelpers.js', + 'x-pack/plugins/canvas/shareable_runtime/postcss.config.js', ], rules: { 'import/no-extraneous-dependencies': [ diff --git a/package.json b/package.json index 51a41cbbab9ffc..880534997cff0c 100644 --- a/package.json +++ b/package.json @@ -480,7 +480,7 @@ "pixelmatch": "^5.1.0", "pkg-up": "^2.0.0", "pngjs": "^3.4.0", - "postcss": "^7.0.26", + "postcss": "^7.0.32", "postcss-url": "^8.0.0", "prettier": "^2.0.5", "proxyquire": "1.8.0", diff --git a/packages/kbn-optimizer/package.json b/packages/kbn-optimizer/package.json index c11bd1b6469332..4fbbc920c4447a 100644 --- a/packages/kbn-optimizer/package.json +++ b/packages/kbn-optimizer/package.json @@ -36,6 +36,7 @@ "loader-utils": "^1.2.3", "node-sass": "^4.13.0", "normalize-path": "^3.0.0", + "postcss": "^7.0.32", "postcss-loader": "^3.0.0", "raw-loader": "^3.1.0", "resolve-url-loader": "^3.1.1", diff --git a/packages/kbn-optimizer/src/worker/postcss.config.js b/packages/kbn-optimizer/postcss.config.js similarity index 100% rename from packages/kbn-optimizer/src/worker/postcss.config.js rename to packages/kbn-optimizer/postcss.config.js diff --git a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts index 2d0d60da1e4a06..bab47d4a1e412f 100644 --- a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts @@ -161,6 +161,7 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { Array [ /node_modules/css-loader/package.json, /node_modules/style-loader/package.json, + /packages/kbn-optimizer/postcss.config.js, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/kibana.json, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/index.scss, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/index.ts, @@ -171,7 +172,6 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/src/legacy/ui/public/styles/_globals_v7dark.scss, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/src/legacy/ui/public/styles/_globals_v7light.scss, /packages/kbn-optimizer/target/worker/entry_point_creator.js, - /packages/kbn-optimizer/target/worker/postcss.config.js, /packages/kbn-ui-shared-deps/public_path_module_creator.js, ] `); diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index 3d62ed16368696..ae5d2b5fb32922 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -152,7 +152,7 @@ export function getWebpackConfig(bundle: Bundle, bundleRefs: BundleRefs, worker: options: { sourceMap: !worker.dist, config: { - path: require.resolve('./postcss.config'), + path: require.resolve('@kbn/optimizer/postcss.config.js'), }, }, }, diff --git a/packages/kbn-storybook/lib/webpack.dll.config.js b/packages/kbn-storybook/lib/webpack.dll.config.js index 740ee3819c36f5..661312b9a0581c 100644 --- a/packages/kbn-storybook/lib/webpack.dll.config.js +++ b/packages/kbn-storybook/lib/webpack.dll.config.js @@ -127,7 +127,7 @@ module.exports = { loader: 'postcss-loader', options: { config: { - path: path.resolve(REPO_ROOT, 'src/optimize/postcss.config.js'), + path: require.resolve('@kbn/optimizer/postcss.config.js'), }, }, }, diff --git a/packages/kbn-storybook/storybook_config/webpack.config.js b/packages/kbn-storybook/storybook_config/webpack.config.js index b2df4f40d4fbe9..0a9977463aee8c 100644 --- a/packages/kbn-storybook/storybook_config/webpack.config.js +++ b/packages/kbn-storybook/storybook_config/webpack.config.js @@ -91,7 +91,7 @@ module.exports = async ({ config }) => { loader: 'postcss-loader', options: { config: { - path: resolve(REPO_ROOT, 'src/optimize/'), + path: require.resolve('@kbn/optimizer/postcss.config.js'), }, }, }, diff --git a/packages/kbn-ui-framework/Gruntfile.js b/packages/kbn-ui-framework/Gruntfile.js index b7ba1e87b2f001..bb8e7b72cb7bd7 100644 --- a/packages/kbn-ui-framework/Gruntfile.js +++ b/packages/kbn-ui-framework/Gruntfile.js @@ -19,7 +19,7 @@ const sass = require('node-sass'); const postcss = require('postcss'); -const postcssConfig = require('../../src/optimize/postcss.config'); +const postcssConfig = require('@kbn/optimizer/postcss.config.js'); const chokidar = require('chokidar'); const { debounce } = require('lodash'); diff --git a/packages/kbn-ui-framework/doc_site/postcss.config.js b/packages/kbn-ui-framework/doc_site/postcss.config.js deleted file mode 100644 index 571bae86dee371..00000000000000 --- a/packages/kbn-ui-framework/doc_site/postcss.config.js +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -module.exports = { - plugins: [require('autoprefixer')()], -}; diff --git a/packages/kbn-ui-framework/package.json b/packages/kbn-ui-framework/package.json index abf64906e02539..7933ce06d6847a 100644 --- a/packages/kbn-ui-framework/package.json +++ b/packages/kbn-ui-framework/package.json @@ -33,7 +33,7 @@ "@babel/core": "^7.10.2", "@elastic/eui": "0.0.55", "@kbn/babel-preset": "1.0.0", - "autoprefixer": "^9.7.4", + "@kbn/optimizer": "1.0.0", "babel-loader": "^8.0.6", "brace": "0.11.1", "chalk": "^2.4.2", @@ -54,7 +54,7 @@ "keymirror": "0.1.1", "moment": "^2.24.0", "node-sass": "^4.13.1", - "postcss": "^7.0.26", + "postcss": "^7.0.32", "postcss-loader": "^3.0.0", "raw-loader": "^3.1.0", "react-dom": "^16.12.0", diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index 8398d1c081da6d..3c03a52383f770 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -21,6 +21,7 @@ "custom-event-polyfill": "^0.3.0", "elasticsearch-browser": "^16.7.0", "jquery": "^3.5.0", + "mini-css-extract-plugin": "0.8.0", "moment": "^2.24.0", "moment-timezone": "^0.5.27", "react": "^16.12.0", diff --git a/src/optimize/base_optimizer.js b/src/optimize/base_optimizer.js index 41628a22641931..74973887ae9c1e 100644 --- a/src/optimize/base_optimizer.js +++ b/src/optimize/base_optimizer.js @@ -34,7 +34,7 @@ import { IS_KIBANA_DISTRIBUTABLE } from '../legacy/utils'; import { fromRoot } from '../core/server/utils'; import { PUBLIC_PATH_PLACEHOLDER } from './public_path_placeholder'; -const POSTCSS_CONFIG_PATH = require.resolve('./postcss.config'); +const POSTCSS_CONFIG_PATH = require.resolve('./postcss.config.js'); const BABEL_PRESET_PATH = require.resolve('@kbn/babel-preset/webpack_preset'); const ISTANBUL_PRESET_PATH = require.resolve('@kbn/babel-preset/istanbul_preset'); const EMPTY_MODULE_PATH = require.resolve('./intentionally_empty_module.js'); diff --git a/x-pack/package.json b/x-pack/package.json index 3a9b3ca606de6c..2d7cb148c43b06 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -121,8 +121,10 @@ "@types/pretty-ms": "^5.0.0", "@welldone-software/why-did-you-render": "^4.0.0", "abab": "^1.0.4", + "autoprefixer": "^9.7.4", "axios": "^0.19.0", "babel-jest": "^25.5.1", + "babel-loader": "^8.0.6", "babel-plugin-require-context-hook": "npm:babel-plugin-require-context-hook-babel7@1.0.0", "base64-js": "^1.3.1", "base64url": "^3.0.1", @@ -159,6 +161,7 @@ "loader-utils": "^1.2.3", "madge": "3.4.4", "marge": "^1.0.1", + "mini-css-extract-plugin": "0.8.0", "mocha": "^7.1.1", "mocha-junit-reporter": "^1.23.1", "mochawesome": "^4.1.0", @@ -168,6 +171,9 @@ "node-fetch": "^2.6.0", "null-loader": "^3.0.0", "pixelmatch": "^5.1.0", + "postcss": "^7.0.32", + "postcss-loader": "^3.0.0", + "postcss-prefix-selector": "^1.7.2", "proxyquire": "1.8.0", "react-docgen-typescript-loader": "^3.1.1", "react-is": "^16.8.0", @@ -308,7 +314,6 @@ "pluralize": "3.1.0", "pngjs": "3.4.0", "polished": "^1.9.2", - "postcss-prefix-selector": "^1.7.2", "prop-types": "^15.6.0", "proper-lockfile": "^3.2.0", "puid": "1.0.7", diff --git a/x-pack/plugins/canvas/shareable_runtime/postcss.config.js b/x-pack/plugins/canvas/shareable_runtime/postcss.config.js index 10baaddfc9b053..e1db6e4a64f710 100644 --- a/x-pack/plugins/canvas/shareable_runtime/postcss.config.js +++ b/x-pack/plugins/canvas/shareable_runtime/postcss.config.js @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -// eslint-disable-next-line const autoprefixer = require('autoprefixer'); const prefixer = require('postcss-prefix-selector'); diff --git a/x-pack/plugins/canvas/shareable_runtime/webpack.config.js b/x-pack/plugins/canvas/shareable_runtime/webpack.config.js index 93dc3dbccd549e..43e422a1615696 100644 --- a/x-pack/plugins/canvas/shareable_runtime/webpack.config.js +++ b/x-pack/plugins/canvas/shareable_runtime/webpack.config.js @@ -111,7 +111,7 @@ module.exports = { loader: 'postcss-loader', options: { config: { - path: path.resolve(KIBANA_ROOT, 'src/optimize/postcss.config.js'), + path: require.resolve('@kbn/optimizer/postcss.config.js'), }, }, }, diff --git a/x-pack/plugins/canvas/storybook/webpack.config.js b/x-pack/plugins/canvas/storybook/webpack.config.js index 927f71b832ba05..982185a731b149 100644 --- a/x-pack/plugins/canvas/storybook/webpack.config.js +++ b/x-pack/plugins/canvas/storybook/webpack.config.js @@ -77,7 +77,9 @@ module.exports = async ({ config }) => { { loader: 'postcss-loader', options: { - path: path.resolve(KIBANA_ROOT, 'src/optimize/postcss.config.js'), + config: { + path: require.resolve('@kbn/optimizer/postcss.config.js'), + }, }, }, { @@ -114,7 +116,9 @@ module.exports = async ({ config }) => { { loader: 'postcss-loader', options: { - path: path.resolve(KIBANA_ROOT, 'src/optimize/postcss.config.js'), + config: { + path: require.resolve('@kbn/optimizer/postcss.config.js'), + }, }, }, { diff --git a/x-pack/plugins/canvas/storybook/webpack.dll.config.js b/x-pack/plugins/canvas/storybook/webpack.dll.config.js index 0e9371e4cb5e45..81d19c035075f7 100644 --- a/x-pack/plugins/canvas/storybook/webpack.dll.config.js +++ b/x-pack/plugins/canvas/storybook/webpack.dll.config.js @@ -114,7 +114,7 @@ module.exports = { loader: 'postcss-loader', options: { config: { - path: path.resolve(KIBANA_ROOT, 'src/optimize/postcss.config.js'), + path: require.resolve('@kbn/optimizer/postcss.config.js'), }, }, }, From 70d4eac30c381802d87a5112825699ae6cd8089f Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Thu, 30 Jul 2020 14:43:33 -0400 Subject: [PATCH 10/11] [Security Solution] Adding tests for endpoint package pipelines (#73703) * Adding tests for endpoint package pipelines * Removing content type check on types that can change based on docker image version * Skipping ingest tests instead of remove expect * Switching ingest tests over to use application/json * Removing country names Co-authored-by: Elastic Machine --- .../apis/epm/file.ts | 6 +- .../ingest_manager_api_integration/config.ts | 4 +- .../apis/fixtures/package_registry_config.yml | 2 + .../apis/index.ts | 1 + .../apis/package.ts | 140 ++++++++++++++++++ .../apis/resolver/entity_id.ts | 20 +-- .../services/resolver.ts | 25 +++- 7 files changed, 168 insertions(+), 30 deletions(-) create mode 100644 x-pack/test/security_solution_endpoint_api_int/apis/package.ts diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/file.ts b/x-pack/test/ingest_manager_api_integration/apis/epm/file.ts index 733b8d4fd9bd65..3f99f91394d2c5 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/epm/file.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/epm/file.ts @@ -47,7 +47,7 @@ export default function ({ getService }: FtrProviderContext) { '/api/ingest_manager/epm/packages/filetest/0.1.0/kibana/visualization/sample_visualization.json' ) .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'text/plain; charset=utf-8') + .expect('Content-Type', 'application/json; charset=utf-8') .expect(200); } else { warnAndSkipTest(this, log); @@ -61,7 +61,7 @@ export default function ({ getService }: FtrProviderContext) { '/api/ingest_manager/epm/packages/filetest/0.1.0/kibana/dashboard/sample_dashboard.json' ) .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'text/plain; charset=utf-8') + .expect('Content-Type', 'application/json; charset=utf-8') .expect(200); } else { warnAndSkipTest(this, log); @@ -73,7 +73,7 @@ export default function ({ getService }: FtrProviderContext) { await supertest .get('/api/ingest_manager/epm/packages/filetest/0.1.0/kibana/search/sample_search.json') .set('kbn-xsrf', 'xxx') - .expect('Content-Type', 'text/plain; charset=utf-8') + .expect('Content-Type', 'application/json; charset=utf-8') .expect(200); } else { warnAndSkipTest(this, log); diff --git a/x-pack/test/ingest_manager_api_integration/config.ts b/x-pack/test/ingest_manager_api_integration/config.ts index ddb49a09a7afa8..85d1c20c7f1554 100644 --- a/x-pack/test/ingest_manager_api_integration/config.ts +++ b/x-pack/test/ingest_manager_api_integration/config.ts @@ -10,9 +10,9 @@ import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; import { defineDockerServersConfig } from '@kbn/test'; // Docker image to use for Ingest Manager API integration tests. -// This hash comes from the commit hash here: https://github.com/elastic/package-storage/commit/48f3935a72b0c5aacc6fec8ef36d559b089a238b +// This hash comes from the commit hash here: https://github.com/elastic/package-storage/commit export const dockerImage = - 'docker.elastic.co/package-registry/distribution:48f3935a72b0c5aacc6fec8ef36d559b089a238b'; + 'docker.elastic.co/package-registry/distribution:80e93ade87f65e18d487b1c407406825915daba8'; export default async function ({ readConfigFile }: FtrConfigProviderContext) { const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/fixtures/package_registry_config.yml b/x-pack/test/security_solution_endpoint_api_int/apis/fixtures/package_registry_config.yml index 4d93386b4d4e14..00e01fe9ea0fc5 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/fixtures/package_registry_config.yml +++ b/x-pack/test/security_solution_endpoint_api_int/apis/fixtures/package_registry_config.yml @@ -1,2 +1,4 @@ package_paths: - /packages/production + - /packages/staging + - /packages/snapshot diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts index 56adc2382e2340..b1317c2d9f1c14 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts @@ -31,5 +31,6 @@ export default function endpointAPIIntegrationTests(providerContext: FtrProvider loadTestFile(require.resolve('./metadata')); loadTestFile(require.resolve('./policy')); loadTestFile(require.resolve('./artifacts')); + loadTestFile(require.resolve('./package')); }); } diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/package.ts b/x-pack/test/security_solution_endpoint_api_int/apis/package.ts new file mode 100644 index 00000000000000..3b5873d1fe0cd6 --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/package.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; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { SearchResponse } from 'elasticsearch'; +import { eventsIndexPattern } from '../../../plugins/security_solution/common/endpoint/constants'; +import { + EndpointDocGenerator, + Event, +} from '../../../plugins/security_solution/common/endpoint/generate_data'; +import { FtrProviderContext } from '../ftr_provider_context'; +import { InsertedEvents, processEventsIndex } from '../services/resolver'; + +interface EventIngested { + event: { + ingested: number; + }; +} + +interface NetworkEvent { + source: { + geo?: { + country_name: string; + }; + }; + destination: { + geo?: { + country_name: string; + }; + }; +} + +const networkIndex = 'logs-endpoint.events.network-default'; + +export default function ({ getService }: FtrProviderContext) { + const resolver = getService('resolverGenerator'); + const es = getService('es'); + const generator = new EndpointDocGenerator('data'); + + const searchForID = async (id: string) => { + return es.search>({ + index: eventsIndexPattern, + body: { + query: { + bool: { + filter: [ + { + ids: { + values: id, + }, + }, + ], + }, + }, + }, + }); + }; + + describe('Endpoint package', () => { + describe('ingested processor', () => { + let event: Event; + let genData: InsertedEvents; + + before(async () => { + event = generator.generateEvent(); + genData = await resolver.insertEvents([event]); + }); + + after(async () => { + await resolver.deleteData(genData); + }); + + it('sets the event.ingested field', async () => { + const resp = await searchForID(genData.eventsInfo[0]._id); + expect(resp.body.hits.hits[0]._source.event.ingested).to.not.be(undefined); + }); + }); + + describe('geoip processor', () => { + let processIndexData: InsertedEvents; + let networkIndexData: InsertedEvents; + + before(async () => { + // 46.239.193.5 should be in Iceland + // 8.8.8.8 should be in the US + const eventWithBothIPs = generator.generateEvent({ + extensions: { source: { ip: '8.8.8.8' }, destination: { ip: '46.239.193.5' } }, + }); + + const eventWithSourceOnly = generator.generateEvent({ + extensions: { source: { ip: '8.8.8.8' } }, + }); + networkIndexData = await resolver.insertEvents( + [eventWithBothIPs, eventWithSourceOnly], + networkIndex + ); + + processIndexData = await resolver.insertEvents([eventWithBothIPs], processEventsIndex); + }); + + after(async () => { + await resolver.deleteData(networkIndexData); + await resolver.deleteData(processIndexData); + }); + + it('sets the geoip fields', async () => { + const eventWithBothIPs = await searchForID( + networkIndexData.eventsInfo[0]._id + ); + // Should be 'United States' + expect(eventWithBothIPs.body.hits.hits[0]._source.source.geo?.country_name).to.not.be( + undefined + ); + // should be 'Iceland' + expect(eventWithBothIPs.body.hits.hits[0]._source.destination.geo?.country_name).to.not.be( + undefined + ); + + const eventWithSourceOnly = await searchForID( + networkIndexData.eventsInfo[1]._id + ); + // Should be 'United States' + expect(eventWithBothIPs.body.hits.hits[0]._source.source.geo?.country_name).to.not.be( + undefined + ); + expect(eventWithSourceOnly.body.hits.hits[0]._source.destination?.geo).to.be(undefined); + }); + + it('does not set geoip fields for events in indices other than the network index', async () => { + const eventWithBothIPs = await searchForID( + processIndexData.eventsInfo[0]._id + ); + expect(eventWithBothIPs.body.hits.hits[0]._source.source.geo).to.be(undefined); + expect(eventWithBothIPs.body.hits.hits[0]._source.destination.geo).to.be(undefined); + }); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity_id.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity_id.ts index 4f2a8013772043..231871fae3d39a 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity_id.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity_id.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ import expect from '@kbn/expect'; -import { SearchResponse } from 'elasticsearch'; import { eventsIndexPattern } from '../../../../plugins/security_solution/common/endpoint/constants'; import { ResolverTree, @@ -20,7 +19,6 @@ import { InsertedEvents } from '../../services/resolver'; export default function resolverAPIIntegrationTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const resolver = getService('resolverGenerator'); - const es = getService('es'); const generator = new EndpointDocGenerator('resolver'); describe('Resolver handling of entity ids', () => { @@ -38,26 +36,10 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC }); it('excludes events that have an empty entity_id field', async () => { - // first lets get the _id of the document using the parent.process.entity_id - // then we'll use the API to search for that specific document - const res = await es.search>({ - index: genData.indices[0], - body: { - query: { - bool: { - filter: [ - { - term: { 'process.parent.entity_id': origin.process.parent!.entity_id }, - }, - ], - }, - }, - }, - }); const { body }: { body: ResolverEntityIndex } = await supertest.get( // using the same indices value here twice to force the query parameter to be an array // for some reason using supertest's query() function doesn't construct a parsable array - `/api/endpoint/resolver/entity?_id=${res.body.hits.hits[0]._id}&indices=${eventsIndexPattern}&indices=${eventsIndexPattern}` + `/api/endpoint/resolver/entity?_id=${genData.eventsInfo[0]._id}&indices=${eventsIndexPattern}&indices=${eventsIndexPattern}` ); expect(body).to.be.empty(); }); diff --git a/x-pack/test/security_solution_endpoint_api_int/services/resolver.ts b/x-pack/test/security_solution_endpoint_api_int/services/resolver.ts index 335689b804d5ba..7e4d4177affac2 100644 --- a/x-pack/test/security_solution_endpoint_api_int/services/resolver.ts +++ b/x-pack/test/security_solution_endpoint_api_int/services/resolver.ts @@ -11,7 +11,7 @@ import { } from '../../../plugins/security_solution/common/endpoint/generate_data'; import { FtrProviderContext } from '../ftr_provider_context'; -const processIndex = 'logs-endpoint.events.process-default'; +export const processEventsIndex = 'logs-endpoint.events.process-default'; /** * Options for build a resolver tree @@ -36,7 +36,7 @@ export interface GeneratedTrees { * Structure containing the events inserted into ES and the index they live in */ export interface InsertedEvents { - events: Event[]; + eventsInfo: Array<{ _id: string; event: Event }>; indices: string[]; } @@ -46,24 +46,37 @@ interface BulkCreateHeader { }; } +interface BulkResponse { + items: Array<{ + create: { + _id: string; + }; + }>; +} + export function ResolverGeneratorProvider({ getService }: FtrProviderContext) { const client = getService('es'); return { async insertEvents( events: Event[], - eventsIndex: string = processIndex + eventsIndex: string = processEventsIndex ): Promise { const body = events.reduce((array: Array, doc) => { array.push({ create: { _index: eventsIndex } }, doc); return array; }, []); - await client.bulk({ body, refresh: true }); - return { events, indices: [eventsIndex] }; + const bulkResp = await client.bulk({ body, refresh: true }); + + const eventsInfo = events.map((event: Event, i: number) => { + return { event, _id: bulkResp.body.items[i].create._id }; + }); + + return { eventsInfo, indices: [eventsIndex] }; }, async createTrees( options: Options, - eventsIndex: string = processIndex, + eventsIndex: string = processEventsIndex, alertsIndex: string = 'logs-endpoint.alerts-default' ): Promise { const seed = options.seed || 'resolver-seed'; From c21474b4ce029b820072b53f3908075404bccbde Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Thu, 30 Jul 2020 14:51:35 -0400 Subject: [PATCH 11/11] [Security Solution][Detections] Change from sha1 to sha256 (#73741) --- .../exceptions/add_exception_modal/index.tsx | 3 +- .../exceptions/edit_exception_modal/index.tsx | 3 +- .../exceptions/exceptionable_fields.json | 26 +++-------- .../components/exceptions/helpers.test.tsx | 45 +++++++++++++++++++ .../common/components/exceptions/helpers.tsx | 36 +++++++++++++-- .../alerts_table/default_config.tsx | 2 +- 6 files changed, 88 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index bb547f05090b7e..e6eaa4947e4040 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -40,6 +40,7 @@ import { AddExceptionComments } from '../add_exception_comments'; import { enrichNewExceptionItemsWithComments, enrichExceptionItemsWithOS, + lowercaseHashValues, defaultEndpointExceptionItems, entryHasListType, entryHasNonEcsType, @@ -256,7 +257,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ : exceptionItemsToAdd; if (exceptionListType === 'endpoint') { const osTypes = retrieveAlertOsTypes(); - enriched = enrichExceptionItemsWithOS(enriched, osTypes); + enriched = lowercaseHashValues(enrichExceptionItemsWithOS(enriched, osTypes)); } return enriched; }, [comment, exceptionItemsToAdd, exceptionListType, retrieveAlertOsTypes]); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index 341d2f2bab37a5..6109b85f2da5a8 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -40,6 +40,7 @@ import { getOperatingSystems, entryHasListType, entryHasNonEcsType, + lowercaseHashValues, } from '../helpers'; import { Loader } from '../../loader'; @@ -195,7 +196,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ ]; if (exceptionListType === 'endpoint') { const osTypes = exceptionItem._tags ? getOperatingSystems(exceptionItem._tags) : []; - enriched = enrichExceptionItemsWithOS(enriched, osTypes); + enriched = lowercaseHashValues(enrichExceptionItemsWithOS(enriched, osTypes)); } return enriched; }, [exceptionItemsToAdd, exceptionItem, comment, exceptionListType]); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_fields.json b/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_fields.json index fdf0ea60ecf6a8..037e340ee7fa2c 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_fields.json +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_fields.json @@ -6,32 +6,25 @@ "Target.process.Ext.code_signature.valid", "Target.process.Ext.services", "Target.process.Ext.user", - "Target.process.command_line", "Target.process.command_line.text", - "Target.process.executable", "Target.process.executable.text", "Target.process.hash.md5", "Target.process.hash.sha1", "Target.process.hash.sha256", "Target.process.hash.sha512", - "Target.process.name", "Target.process.name.text", "Target.process.parent.Ext.code_signature.status", "Target.process.parent.Ext.code_signature.subject_name", "Target.process.parent.Ext.code_signature.trusted", "Target.process.parent.Ext.code_signature.valid", - "Target.process.parent.command_line", "Target.process.parent.command_line.text", - "Target.process.parent.executable", "Target.process.parent.executable.text", "Target.process.parent.hash.md5", "Target.process.parent.hash.sha1", "Target.process.parent.hash.sha256", "Target.process.parent.hash.sha512", - "Target.process.parent.name", "Target.process.parent.name.text", "Target.process.parent.pgid", - "Target.process.parent.working_directory", "Target.process.parent.working_directory.text", "Target.process.pe.company", "Target.process.pe.description", @@ -39,7 +32,6 @@ "Target.process.pe.original_file_name", "Target.process.pe.product", "Target.process.pgid", - "Target.process.working_directory", "Target.process.working_directory.text", "agent.id", "agent.type", @@ -74,7 +66,6 @@ "file.mode", "file.name", "file.owner", - "file.path", "file.path.text", "file.pe.company", "file.pe.description", @@ -82,7 +73,6 @@ "file.pe.original_file_name", "file.pe.product", "file.size", - "file.target_path", "file.target_path.text", "file.type", "file.uid", @@ -94,10 +84,8 @@ "host.id", "host.os.Ext.variant", "host.os.family", - "host.os.full", "host.os.full.text", "host.os.kernel", - "host.os.name", "host.os.name.text", "host.os.platform", "host.os.version", @@ -108,32 +96,25 @@ "process.Ext.code_signature.valid", "process.Ext.services", "process.Ext.user", - "process.command_line", "process.command_line.text", - "process.executable", "process.executable.text", "process.hash.md5", "process.hash.sha1", "process.hash.sha256", "process.hash.sha512", - "process.name", "process.name.text", "process.parent.Ext.code_signature.status", "process.parent.Ext.code_signature.subject_name", "process.parent.Ext.code_signature.trusted", "process.parent.Ext.code_signature.valid", - "process.parent.command_line", "process.parent.command_line.text", - "process.parent.executable", "process.parent.executable.text", "process.parent.hash.md5", "process.parent.hash.sha1", "process.parent.hash.sha256", "process.parent.hash.sha512", - "process.parent.name", "process.parent.name.text", "process.parent.pgid", - "process.parent.working_directory", "process.parent.working_directory.text", "process.pe.company", "process.pe.description", @@ -141,7 +122,10 @@ "process.pe.original_file_name", "process.pe.product", "process.pgid", - "process.working_directory", "process.working_directory.text", - "rule.uuid" + "rule.uuid", + "user.domain", + "user.email", + "user.hash", + "user.id" ] \ No newline at end of file diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index 5cb65ee6db8ffc..18b509d16b352c 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -24,6 +24,7 @@ import { entryHasListType, entryHasNonEcsType, prepareExceptionItemsForBulkClose, + lowercaseHashValues, } from './helpers'; import { EmptyEntry } from './types'; import { @@ -663,4 +664,48 @@ describe('Exception helpers', () => { expect(result).toEqual(expected); }); }); + + describe('#lowercaseHashValues', () => { + test('it should return an empty array with an empty array', () => { + const payload: ExceptionListItemSchema[] = []; + const result = lowercaseHashValues(payload); + expect(result).toEqual([]); + }); + + test('it should return all list items with entry hashes lowercased', () => { + const payload = [ + { + ...getExceptionListItemSchemaMock(), + entries: [{ field: 'user.hash', type: 'match', value: 'DDDFFF' }] as EntriesArray, + }, + { + ...getExceptionListItemSchemaMock(), + entries: [{ field: 'user.hash', type: 'match', value: 'aaabbb' }] as EntriesArray, + }, + { + ...getExceptionListItemSchemaMock(), + entries: [ + { field: 'user.hash', type: 'match_any', value: ['aaabbb', 'DDDFFF'] }, + ] as EntriesArray, + }, + ]; + const result = lowercaseHashValues(payload); + expect(result).toEqual([ + { + ...getExceptionListItemSchemaMock(), + entries: [{ field: 'user.hash', type: 'match', value: 'dddfff' }] as EntriesArray, + }, + { + ...getExceptionListItemSchemaMock(), + entries: [{ field: 'user.hash', type: 'match', value: 'aaabbb' }] as EntriesArray, + }, + { + ...getExceptionListItemSchemaMock(), + entries: [ + { field: 'user.hash', type: 'match_any', value: ['aaabbb', 'dddfff'] }, + ] as EntriesArray, + }, + ]); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 3abb788312ff43..2b526ede12acfd 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -335,6 +335,36 @@ export const enrichExceptionItemsWithOS = ( }); }; +/** + * Returns given exceptionItems with all hash-related entries lowercased + */ +export const lowercaseHashValues = ( + exceptionItems: Array +): Array => { + return exceptionItems.map((item) => { + const newEntries = item.entries.map((itemEntry) => { + if (itemEntry.field.includes('.hash')) { + if (itemEntry.type === 'match') { + return { + ...itemEntry, + value: itemEntry.value.toLowerCase(), + }; + } else if (itemEntry.type === 'match_any') { + return { + ...itemEntry, + value: itemEntry.value.map((val) => val.toLowerCase()), + }; + } + } + return itemEntry; + }); + return { + ...item, + entries: newEntries, + }; + }); +}; + /** * Returns the value for the given fieldname within TimelineNonEcsData if it exists */ @@ -413,7 +443,7 @@ export const defaultEndpointExceptionItems = ( data: alertData, fieldName: 'file.Ext.code_signature.trusted', }); - const [sha1Hash] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.hash.sha1' }); + const [sha256Hash] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.hash.sha256' }); const [eventCode] = getMappedNonEcsValue({ data: alertData, fieldName: 'event.code' }); const namespaceType = 'agnostic'; @@ -446,10 +476,10 @@ export const defaultEndpointExceptionItems = ( value: filePath ?? '', }, { - field: 'file.hash.sha1', + field: 'file.hash.sha256', operator: 'included', type: 'match', - value: sha1Hash ?? '', + value: sha256Hash ?? '', }, { field: 'event.code', diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index 010129d2d45933..f38a9107afca98 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -202,7 +202,7 @@ export const requiredFieldsForActions = [ 'file.path', 'file.Ext.code_signature.subject_name', 'file.Ext.code_signature.trusted', - 'file.hash.sha1', + 'file.hash.sha256', 'host.os.family', 'event.code', ];