From 88fdebdc8114394f0f87758187292003c6548007 Mon Sep 17 00:00:00 2001 From: Kevin Lacabane Date: Mon, 25 Sep 2023 18:28:09 +0200 Subject: [PATCH 01/12] [apm] allow retrieval of metric indices (#167041) ### Summary Closes https://github.com/elastic/kibana/issues/166961 `/internal/apm/services/{serviceName}/infrastructure_attributes` route was disabled in serverless as it relied on an infra API to function. Since the infra plugin dependency was removed in https://github.com/elastic/kibana/pull/164094 we can reenable the route ### Testing I used a ccs cluster connected to edge-oblt and had to update the apm indices to also search the remote_cluster ``` xpack.apm.indices.metric: remote_cluster:metrics-apm*,remote_cluster:apm*,metrics-apm*,apm* xpack.apm.indices.transaction: remote_cluster:traces-apm*,remote_cluster:apm*,traces-apm*,apm* xpack.apm.indices.span: remote_cluster:traces-apm*,remote_cluster:apm*,traces-apm*,apm* xpack.apm.indices.error: remote_cluster:logs-apm*,remote_cluster:apm*,logs-apm*,apm* ``` - start serverless kibana - navigate to Applications -> Services, we need to select a [service linked to a container](https://github.com/elastic/kibana/blob/main/x-pack/plugins/apm/server/routes/infrastructure/get_host_names.ts#L23) to fully trigger the route logic (you can pick `quoteservice` if connected to edge-oblt data) - navigate to Logs tab - call to `/infrastructure_attributes` is successful --- .../create_infra_metrics_client.ts | 2 +- .../lib/helpers/get_infra_metric_indices.ts | 25 ------------------- .../apm/server/routes/infrastructure/route.ts | 5 ---- .../apm/server/routes/services/route.ts | 7 ++---- .../server/client/client.test.ts | 25 +++++++++++++------ .../server/client/client.ts | 8 +++--- 6 files changed, 24 insertions(+), 48 deletions(-) delete mode 100644 x-pack/plugins/apm/server/lib/helpers/get_infra_metric_indices.ts diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_infra_metrics_client/create_infra_metrics_client.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_infra_metrics_client/create_infra_metrics_client.ts index f7ae6ea53147a..eff505b22bd4a 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_infra_metrics_client/create_infra_metrics_client.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_infra_metrics_client/create_infra_metrics_client.ts @@ -6,7 +6,7 @@ */ import { ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types'; -import { APMRouteHandlerResources } from '../../../../routes/typings'; +import { APMRouteHandlerResources } from '../../../../routes/apm_routes/register_apm_server_routes'; type InfraMetricsSearchParams = Omit & { size: number; diff --git a/x-pack/plugins/apm/server/lib/helpers/get_infra_metric_indices.ts b/x-pack/plugins/apm/server/lib/helpers/get_infra_metric_indices.ts deleted file mode 100644 index 24b76edb4d887..0000000000000 --- a/x-pack/plugins/apm/server/lib/helpers/get_infra_metric_indices.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SavedObjectsClientContract } from '@kbn/core/server'; -import { APMRouteHandlerResources } from '../../routes/apm_routes/register_apm_server_routes'; - -export async function getInfraMetricIndices({ - infraPlugin, - savedObjectsClient, -}: { - infraPlugin: Required; - savedObjectsClient: SavedObjectsClientContract; -}): Promise { - if (!infraPlugin) { - throw new Error('Infra Plugin needs to be setup'); - } - const infra = await infraPlugin.start(); - const infraMetricIndices = await infra.getMetricIndices(savedObjectsClient); - - return infraMetricIndices; -} diff --git a/x-pack/plugins/apm/server/routes/infrastructure/route.ts b/x-pack/plugins/apm/server/routes/infrastructure/route.ts index 4117a43ce1e3f..9050f1a46622c 100644 --- a/x-pack/plugins/apm/server/routes/infrastructure/route.ts +++ b/x-pack/plugins/apm/server/routes/infrastructure/route.ts @@ -5,7 +5,6 @@ * 2.0. */ import * as t from 'io-ts'; -import Boom from '@hapi/boom'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { getApmEventClient } from '../../lib/helpers/get_apm_event_client'; import { environmentRt, kueryRt, rangeRt } from '../default_api_types'; @@ -30,10 +29,6 @@ const infrastructureRoute = createApmServerRoute({ hostNames: string[]; podNames: string[]; }> => { - if (!resources.plugins.infra) { - throw Boom.notFound(); - } - const apmEventClient = await getApmEventClient(resources); const infraMetricsClient = createInfraMetricsClient(resources); const { params } = resources; diff --git a/x-pack/plugins/apm/server/routes/services/route.ts b/x-pack/plugins/apm/server/routes/services/route.ts index 4ac0a37b3d10d..970a72d478f72 100644 --- a/x-pack/plugins/apm/server/routes/services/route.ts +++ b/x-pack/plugins/apm/server/routes/services/route.ts @@ -250,7 +250,7 @@ const serviceMetadataDetailsRoute = createApmServerRoute({ end, }); - if (serviceMetadataDetails?.container?.ids && resources.plugins.infra) { + if (serviceMetadataDetails?.container?.ids) { const infraMetricsClient = createInfraMetricsClient(resources); const containerMetadata = await getServiceOverviewContainerMetadata({ infraMetricsClient, @@ -761,10 +761,7 @@ export const serviceInstancesMetadataDetails = createApmServerRoute({ end, }); - if ( - serviceInstanceMetadataDetails?.container?.id && - resources.plugins.infra - ) { + if (serviceInstanceMetadataDetails?.container?.id) { const infraMetricsClient = createInfraMetricsClient(resources); const containerMetadata = await getServiceInstanceContainerMetadata({ infraMetricsClient, diff --git a/x-pack/plugins/metrics_data_access/server/client/client.test.ts b/x-pack/plugins/metrics_data_access/server/client/client.test.ts index 72449cf47132b..d96d8efecf52f 100644 --- a/x-pack/plugins/metrics_data_access/server/client/client.test.ts +++ b/x-pack/plugins/metrics_data_access/server/client/client.test.ts @@ -7,18 +7,13 @@ import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; -import { MetricsDataClient } from './client'; +import { MetricsDataClient, DEFAULT_METRIC_INDICES } from './client'; import { metricsDataSourceSavedObjectName } from '../saved_objects/metrics_data_source'; describe('MetricsDataClient', () => { - const client = new MetricsDataClient(); - - client.setDefaultMetricIndicesHandler(async () => { - return 'fallback-indices*'; - }); - describe('metric indices', () => { it('retrieves metrics saved object', async () => { + const client = new MetricsDataClient(); const savedObjectsClient = { get: jest.fn().mockResolvedValue({ attributes: { metricIndices: 'foo,bar' } }), }; @@ -36,6 +31,10 @@ describe('MetricsDataClient', () => { }); it('falls back to provided handler when no metrics saved object exists', async () => { + const client = new MetricsDataClient(); + client.setDefaultMetricIndicesHandler(async () => { + return 'fallback-indices*'; + }); const savedObjectsClient = { get: jest.fn().mockRejectedValue(SavedObjectsErrorHelpers.createGenericNotFoundError()), }; @@ -51,5 +50,17 @@ describe('MetricsDataClient', () => { ]); expect(indices).toEqual('fallback-indices*'); }); + + it('falls back to static indices when no fallback exists', async () => { + const client = new MetricsDataClient(); + const savedObjectsClient = { + get: jest.fn().mockRejectedValue(SavedObjectsErrorHelpers.createGenericNotFoundError()), + }; + + const indices = await client.getMetricIndices({ + savedObjectsClient: savedObjectsClient as unknown as SavedObjectsClientContract, + }); + expect(indices).toEqual(DEFAULT_METRIC_INDICES); + }); }); }); diff --git a/x-pack/plugins/metrics_data_access/server/client/client.ts b/x-pack/plugins/metrics_data_access/server/client/client.ts index 30d367cea0293..26359cae578a7 100644 --- a/x-pack/plugins/metrics_data_access/server/client/client.ts +++ b/x-pack/plugins/metrics_data_access/server/client/client.ts @@ -16,21 +16,19 @@ import { metricsDataSourceSavedObjectName, } from '../saved_objects/metrics_data_source'; +export const DEFAULT_METRIC_INDICES = 'metrics-*,metricbeat-*'; + export class MetricsDataClient { private readonly defaultSavedObjectId = 'default'; private getDefaultMetricIndices: DefaultMetricIndicesHandler = null; async getMetricIndices(options: GetMetricIndicesOptions): Promise { - if (!this.getDefaultMetricIndices) { - throw new Error('Missing getMetricsIndices fallback'); - } - const metricIndices = await options.savedObjectsClient .get(metricsDataSourceSavedObjectName, this.defaultSavedObjectId) .then(({ attributes }) => attributes.metricIndices) .catch((err) => { if (SavedObjectsErrorHelpers.isNotFoundError(err)) { - return this.getDefaultMetricIndices!(options); + return this.getDefaultMetricIndices?.(options) ?? DEFAULT_METRIC_INDICES; } throw err; From 8c853b6ca77735b17db4b4f384e5d426189a755b Mon Sep 17 00:00:00 2001 From: Paulo Henrique Date: Mon, 25 Sep 2023 09:32:12 -0700 Subject: [PATCH 02/12] [Cloud Security] [CSPM] Update cloud native deployment instructions (#166419) --- .../aws_credentials_form/aws_credentials_form.tsx | 6 ++++++ .../components/fleet_extensions/gcp_credential_form.tsx | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/aws_credentials_form/aws_credentials_form.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/aws_credentials_form/aws_credentials_form.tsx index b1774547a6e6d..fdc7d8c0f328f 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/aws_credentials_form/aws_credentials_form.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/aws_credentials_form/aws_credentials_form.tsx @@ -133,6 +133,12 @@ const CloudFormationSetup = ({ list-style: auto; `} > +
  • + +
  • {accountType === AWS_ORGANIZATION_ACCOUNT ? (
  • { list-style: auto; `} > +
  • + +
  • Date: Mon, 25 Sep 2023 09:32:53 -0700 Subject: [PATCH 03/12] [Cloud Security] [Dashboard Navigation] Fix edit filter when navigating from dashboard (#166500) --- .../common/constants.ts | 2 + .../hooks/use_navigate_findings.test.ts | 40 ++++++++++++++++--- .../common/hooks/use_navigate_findings.ts | 27 +++++++++---- 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index e1bcdc1f9a95f..7f0b4f62fb216 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -65,6 +65,8 @@ export const LATEST_VULNERABILITIES_RETENTION_POLICY = '3d'; export const DATA_VIEW_INDEX_PATTERN = 'logs-*'; +export const SECURITY_DEFAULT_DATA_VIEW_ID = 'security-solution-default'; + export const CSP_INGEST_TIMESTAMP_PIPELINE = 'cloud_security_posture_add_ingest_timestamp_pipeline'; export const CSP_LATEST_FINDINGS_INGEST_TIMESTAMP_PIPELINE = 'cloud_security_posture_latest_index_add_ingest_timestamp_pipeline'; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.test.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.test.ts index 9bdd3bfada098..e3d213118dd51 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.test.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.test.ts @@ -6,7 +6,11 @@ */ import { renderHook, act } from '@testing-library/react-hooks/dom'; -import { useNavigateFindings, useNavigateFindingsByResource } from './use_navigate_findings'; +import { + useNavigateFindings, + useNavigateFindingsByResource, + useNavigateVulnerabilities, +} from './use_navigate_findings'; import { useHistory } from 'react-router-dom'; jest.mock('react-router-dom', () => ({ @@ -29,9 +33,17 @@ jest.mock('./use_kibana', () => ({ }, }), })); +jest.mock('../api/use_latest_findings_data_view', () => ({ + useLatestFindingsDataView: jest.fn().mockReturnValue({ + status: 'success', + data: { + id: 'data-view-id', + }, + }), +})); describe('useNavigateFindings', () => { - it('creates a URL to findings page with correct path and filter', () => { + it('creates a URL to findings page with correct path, filter and dataViewId', () => { const push = jest.fn(); (useHistory as jest.Mock).mockReturnValueOnce({ push }); @@ -44,7 +56,7 @@ describe('useNavigateFindings', () => { expect(push).toHaveBeenCalledWith({ pathname: '/cloud_security_posture/findings/configurations', search: - "cspq=(filters:!((meta:(alias:!n,disabled:!f,key:foo,negate:!f,type:phrase),query:(match_phrase:(foo:1)))),query:(language:kuery,query:''))", + "cspq=(filters:!((meta:(alias:!n,disabled:!f,index:data-view-id,key:foo,negate:!f,type:phrase),query:(match_phrase:(foo:1)))),query:(language:kuery,query:''))", }); expect(push).toHaveBeenCalledTimes(1); }); @@ -62,7 +74,7 @@ describe('useNavigateFindings', () => { expect(push).toHaveBeenCalledWith({ pathname: '/cloud_security_posture/findings/configurations', search: - "cspq=(filters:!((meta:(alias:!n,disabled:!f,key:foo,negate:!t,type:phrase),query:(match_phrase:(foo:1)))),query:(language:kuery,query:''))", + "cspq=(filters:!((meta:(alias:!n,disabled:!f,index:data-view-id,key:foo,negate:!t,type:phrase),query:(match_phrase:(foo:1)))),query:(language:kuery,query:''))", }); expect(push).toHaveBeenCalledTimes(1); }); @@ -80,7 +92,25 @@ describe('useNavigateFindings', () => { expect(push).toHaveBeenCalledWith({ pathname: '/cloud_security_posture/findings/resource', search: - "cspq=(filters:!((meta:(alias:!n,disabled:!f,key:foo,negate:!f,type:phrase),query:(match_phrase:(foo:1)))),query:(language:kuery,query:''))", + "cspq=(filters:!((meta:(alias:!n,disabled:!f,index:data-view-id,key:foo,negate:!f,type:phrase),query:(match_phrase:(foo:1)))),query:(language:kuery,query:''))", + }); + expect(push).toHaveBeenCalledTimes(1); + }); + + it('creates a URL to vulnerabilities page with correct path, filter and dataViewId', () => { + const push = jest.fn(); + (useHistory as jest.Mock).mockReturnValueOnce({ push }); + + const { result } = renderHook(() => useNavigateVulnerabilities()); + + act(() => { + result.current({ foo: 1 }); + }); + + expect(push).toHaveBeenCalledWith({ + pathname: '/cloud_security_posture/findings/vulnerabilities', + search: + "cspq=(filters:!((meta:(alias:!n,disabled:!f,index:security-solution-default,key:foo,negate:!f,type:phrase),query:(match_phrase:(foo:1)))),query:(language:kuery,query:''))", }); expect(push).toHaveBeenCalledTimes(1); }); diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts index 48b16f62cbaf5..fbeeeb32a0c2e 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts @@ -8,9 +8,14 @@ import { useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import { Filter } from '@kbn/es-query'; +import { + LATEST_FINDINGS_INDEX_PATTERN, + SECURITY_DEFAULT_DATA_VIEW_ID, +} from '../../../common/constants'; import { findingsNavigation } from '../navigation/constants'; import { encodeQuery } from '../navigation/query_utils'; import { useKibana } from './use_kibana'; +import { useLatestFindingsDataView } from '../api/use_latest_findings_data_view'; interface NegatedValue { value: string | number; @@ -21,7 +26,7 @@ type FilterValue = string | number | NegatedValue; export type NavFilter = Record; -const createFilter = (key: string, filterValue: FilterValue): Filter => { +const createFilter = (key: string, filterValue: FilterValue, dataViewId: string): Filter => { let negate = false; let value = filterValue; if (typeof filterValue === 'object') { @@ -32,7 +37,7 @@ const createFilter = (key: string, filterValue: FilterValue): Filter => { if (value === '*') { return { query: { exists: { field: key } }, - meta: { type: 'exists' }, + meta: { type: 'exists', index: dataViewId }, }; } return { @@ -42,18 +47,19 @@ const createFilter = (key: string, filterValue: FilterValue): Filter => { disabled: false, type: 'phrase', key, + index: dataViewId, }, query: { match_phrase: { [key]: value } }, }; }; -const useNavigate = (pathname: string) => { +const useNavigate = (pathname: string, dataViewId = SECURITY_DEFAULT_DATA_VIEW_ID) => { const history = useHistory(); const { services } = useKibana(); return useCallback( (filterParams: NavFilter = {}) => { const filters = Object.entries(filterParams).map(([key, filterValue]) => - createFilter(key, filterValue) + createFilter(key, filterValue, dataViewId) ); history.push({ @@ -65,14 +71,19 @@ const useNavigate = (pathname: string) => { }), }); }, - [pathname, history, services.data.query.queryString] + [pathname, history, services.data.query.queryString, dataViewId] ); }; -export const useNavigateFindings = () => useNavigate(findingsNavigation.findings_default.path); +export const useNavigateFindings = () => { + const { data } = useLatestFindingsDataView(LATEST_FINDINGS_INDEX_PATTERN); + return useNavigate(findingsNavigation.findings_default.path, data?.id); +}; -export const useNavigateFindingsByResource = () => - useNavigate(findingsNavigation.findings_by_resource.path); +export const useNavigateFindingsByResource = () => { + const { data } = useLatestFindingsDataView(LATEST_FINDINGS_INDEX_PATTERN); + return useNavigate(findingsNavigation.findings_by_resource.path, data?.id); +}; export const useNavigateVulnerabilities = () => useNavigate(findingsNavigation.vulnerabilities.path); From 778dbf26b9bcd5b43e53a41b1646974dfe9352ea Mon Sep 17 00:00:00 2001 From: Paulo Henrique Date: Mon, 25 Sep 2023 09:33:13 -0700 Subject: [PATCH 04/12] [Cloud Security] [Misconfigurations] Test coverage for the Alerts workflow (#166788) --- .../detection_rule_counter.test.tsx | 138 ++++++++++ .../components/detection_rule_counter.tsx | 24 +- .../public/components/take_action.tsx | 9 +- .../page_objects/findings_page.ts | 70 +++++- .../pages/findings_alerts.ts | 236 ++++++++++++++++++ .../pages/index.ts | 1 + 6 files changed, 468 insertions(+), 10 deletions(-) create mode 100644 x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.test.tsx create mode 100644 x-pack/test/cloud_security_posture_functional/pages/findings_alerts.ts diff --git a/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.test.tsx b/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.test.tsx new file mode 100644 index 0000000000000..1e2f2f52fd02a --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.test.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DetectionRuleCounter } from './detection_rule_counter'; +import { TestProvider } from '../test/test_provider'; +import { useFetchDetectionRulesByTags } from '../common/api/use_fetch_detection_rules_by_tags'; +import { useFetchDetectionRulesAlertsStatus } from '../common/api/use_fetch_detection_rules_alerts_status'; +import { RuleResponse } from '../common/types'; + +jest.mock('../common/api/use_fetch_detection_rules_by_tags', () => ({ + useFetchDetectionRulesByTags: jest.fn(), +})); +jest.mock('../common/api/use_fetch_detection_rules_alerts_status', () => ({ + useFetchDetectionRulesAlertsStatus: jest.fn(), +})); + +describe('DetectionRuleCounter', () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + it('should render loading skeleton when both rules and alerts are loading', () => { + (useFetchDetectionRulesByTags as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: true, + }); + + (useFetchDetectionRulesAlertsStatus as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: true, + }); + const { getByTestId } = render( + + + + ); + + const skeletonText = getByTestId('csp:detection-rule-counter-loading'); + expect(skeletonText).toBeInTheDocument(); + }); + + it('should render create rule link when no rules exist', () => { + (useFetchDetectionRulesByTags as jest.Mock).mockReturnValue({ + data: { total: 0 }, + isLoading: false, + }); + + (useFetchDetectionRulesAlertsStatus as jest.Mock).mockReturnValue({ + data: null, + isLoading: false, + isFetching: false, + }); + + const { getByText, getByTestId } = render( + + + + ); + + const createRuleLink = getByTestId('csp:findings-flyout-create-detection-rule-link'); + expect(createRuleLink).toBeInTheDocument(); + expect(getByText('Create a detection rule')).toBeInTheDocument(); + }); + + it('should render alert and rule count when rules exist', () => { + (useFetchDetectionRulesByTags as jest.Mock).mockReturnValue({ + data: { total: 5 }, + isLoading: false, + }); + + (useFetchDetectionRulesAlertsStatus as jest.Mock).mockReturnValue({ + data: { total: 10 }, + isLoading: false, + isFetching: false, + }); + + const { getByText, getByTestId } = render( + + + + ); + + const alertCountLink = getByTestId('csp:findings-flyout-alert-count'); + const ruleCountLink = getByTestId('csp:findings-flyout-detection-rule-count'); + + expect(alertCountLink).toBeInTheDocument(); + expect(getByText(/10 alerts/i)).toBeInTheDocument(); + expect(ruleCountLink).toBeInTheDocument(); + expect(getByText(/5 detection rules/i)).toBeInTheDocument(); + }); + + it('should show loading spinner when creating a rule', async () => { + (useFetchDetectionRulesByTags as jest.Mock).mockReturnValue({ + data: { total: 0 }, + isLoading: false, + }); + + (useFetchDetectionRulesAlertsStatus as jest.Mock).mockReturnValue({ + data: null, + isLoading: false, + isFetching: false, + }); + const createRuleFn = jest.fn(() => Promise.resolve({} as RuleResponse)); + const { getByTestId, queryByTestId } = render( + + + + ); + + // Trigger createDetectionRuleOnClick + const createRuleLink = getByTestId('csp:findings-flyout-create-detection-rule-link'); + userEvent.click(createRuleLink); + + const loadingSpinner = getByTestId('csp:findings-flyout-detection-rule-counter-loading'); + expect(loadingSpinner).toBeInTheDocument(); + + (useFetchDetectionRulesByTags as jest.Mock).mockReturnValue({ + data: { total: 1 }, + isLoading: false, + }); + + (useFetchDetectionRulesAlertsStatus as jest.Mock).mockReturnValue({ + data: { total: 0 }, + isLoading: false, + isFetching: false, + }); + + // Wait for the loading spinner to disappear + await waitFor(() => { + expect(queryByTestId('csp:findings-flyout-detection-rule-counter-loading')).toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.tsx b/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.tsx index eeea89f9a310f..b2a79710d09a2 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.tsx @@ -68,7 +68,12 @@ export const DetectionRuleCounter = ({ tags, createRuleFn }: DetectionRuleCounte }, [createRuleFn, http, notifications, queryClient]); return ( - + {rulesData?.total === 0 ? ( <> @@ -78,11 +83,17 @@ export const DetectionRuleCounter = ({ tags, createRuleFn }: DetectionRuleCounte id="xpack.csp.findingsFlyout.alerts.creatingRule" defaultMessage="Creating detection rule" />{' '} - + ) : ( <> - + ) : ( <> - + {' '} - + - {ruleResponse.name} + {ruleResponse.name} {` `} - + (findingsMock: T[]) => { + add: async < + T extends { + '@timestamp'?: string; + } + >( + findingsMock: T[] + ) => { await Promise.all([ ...findingsMock.map((finding) => es.index({ index: FINDINGS_INDEX, body: { ...finding, - '@timestamp': new Date().toISOString(), + '@timestamp': finding['@timestamp'] ?? new Date().toISOString(), }, refresh: true, }) @@ -72,7 +78,7 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider index: FINDINGS_LATEST_INDEX, body: { ...finding, - '@timestamp': new Date().toISOString(), + '@timestamp': finding['@timestamp'] ?? new Date().toISOString(), }, refresh: true, }) @@ -81,6 +87,20 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider }, }; + const detectionRuleApi = { + remove: async () => { + await supertest + .post('/api/detection_engine/rules/_bulk_action?dry_run=false') + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') + .send({ + action: 'delete', + query: '', + }) + .expect(200); + }, + }; + const distributionBar = { filterBy: async (type: 'passed' | 'failed') => testSubjects.click(type === 'failed' ? 'distribution_bar_failed' : 'distribution_bar_passed'), @@ -203,6 +223,12 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider await nonStaleElement.click(); } }, + + async openFlyoutAt(rowIndex: number) { + const table = await this.getElement(); + const flyoutButton = await table.findAllByTestSubject('findings_table_expand_column'); + await flyoutButton[rowIndex].click(); + }, }); const navigateToLatestFindingsPage = async () => { @@ -247,6 +273,41 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider const notInstalledVulnerabilities = createNotInstalledObject('cnvm-integration-not-installed'); const notInstalledCSP = createNotInstalledObject('cloud_posture_page_package_not_installed'); + const createFlyoutObject = (tableTestSubject: string) => ({ + async getElement() { + return await testSubjects.find(tableTestSubject); + }, + async clickTakeActionButton() { + const element = await this.getElement(); + const button = await element.findByCssSelector('[data-test-subj="csp:take_action"] button'); + await button.click(); + return button; + }, + async clickTakeActionCreateRuleButton() { + await this.clickTakeActionButton(); + const button = await testSubjects.find('csp:create_rule'); + await button.click(); + return button; + }, + async getVisibleText(testSubj: string) { + const element = await this.getElement(); + return await (await element.findByTestSubject(testSubj)).getVisibleText(); + }, + }); + + const misconfigurationsFlyout = createFlyoutObject('findings_flyout'); + + const toastMessage = async (testSubj = 'csp:toast-success') => ({ + async getElement() { + return await testSubjects.find(testSubj); + }, + async clickToastMessageLink(linkTestSubj = 'csp:toast-success-link') { + const element = await this.getElement(); + const link = await element.findByTestSubject(linkTestSubj); + await link.click(); + }, + }); + return { navigateToLatestFindingsPage, navigateToVulnerabilities, @@ -259,5 +320,8 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider index, waitForPluginInitialized, distributionBar, + misconfigurationsFlyout, + toastMessage, + detectionRuleApi, }; } diff --git a/x-pack/test/cloud_security_posture_functional/pages/findings_alerts.ts b/x-pack/test/cloud_security_posture_functional/pages/findings_alerts.ts new file mode 100644 index 0000000000000..53f0b765165ef --- /dev/null +++ b/x-pack/test/cloud_security_posture_functional/pages/findings_alerts.ts @@ -0,0 +1,236 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import Chance from 'chance'; +import type { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const pageObjects = getPageObjects(['common', 'findings', 'header']); + const chance = new Chance(); + + // We need to use a dataset for the tests to run + const data = [ + { + resource: { id: chance.guid(), name: `kubelet`, sub_type: 'lower case sub type' }, + result: { evaluation: chance.integer() % 2 === 0 ? 'passed' : 'failed' }, + rule: { + tags: ['CIS', 'CIS K8S'], + rationale: 'rationale steps for rule 1.1', + references: '1. https://elastic.co/rules/1.1', + name: 'Upper case rule name', + section: 'Upper case section', + benchmark: { + rule_number: '1.1', + id: 'cis_k8s', + posture_type: 'kspm', + name: 'CIS Kubernetes V1.23', + version: 'v1.0.0', + remediation: 'remediation guide', + }, + type: 'process', + }, + cluster_id: 'Upper case cluster id', + }, + { + '@timestamp': '2023-09-10T14:01:00.000Z', + resource: { id: chance.guid(), name: `Pod`, sub_type: 'Upper case sub type' }, + result: { evaluation: chance.integer() % 2 === 0 ? 'passed' : 'failed' }, + rule: { + tags: ['CIS', 'CIS K8S'], + rationale: 'rationale steps', + references: '1. https://elastic.co', + name: 'lower case rule name', + section: 'Another upper case section', + benchmark: { + rule_number: '1.2', + id: 'cis_k8s', + posture_type: 'kspm', + name: 'CIS Kubernetes V1.23', + version: 'v1.0.0', + remediation: 'remediation guide', + }, + type: 'process', + }, + cluster_id: 'Another Upper case cluster id', + }, + { + '@timestamp': '2023-09-10T14:02:00.000Z', + resource: { id: chance.guid(), name: `process`, sub_type: 'another lower case type' }, + result: { evaluation: 'passed' }, + rule: { + tags: ['CIS', 'CIS K8S'], + rationale: 'rationale steps', + references: '1. https://elastic.co', + name: 'Another upper case rule name', + section: 'lower case section', + benchmark: { + rule_number: '1.3', + id: 'cis_k8s', + posture_type: 'kspm', + name: 'CIS Kubernetes V1.23', + version: 'v1.0.0', + remediation: 'remediation guide', + }, + type: 'process', + }, + cluster_id: 'lower case cluster id', + }, + { + '@timestamp': '2023-09-10T14:03:00.000Z', + resource: { id: chance.guid(), name: `process`, sub_type: 'Upper case type again' }, + result: { evaluation: 'failed' }, + rule: { + tags: ['CIS', 'CIS K8S'], + rationale: 'rationale steps', + references: '1. https://elastic.co', + name: 'some lower case rule name', + section: 'another lower case section', + benchmark: { + rule_number: '1.4', + id: 'cis_k8s', + posture_type: 'kspm', + name: 'CIS Kubernetes V1.23', + version: 'v1.0.0', + remediation: 'remediation guide', + }, + type: 'process', + }, + cluster_id: 'another lower case cluster id', + }, + ]; + + const ruleName1 = data[0].rule.name; + + describe('Findings Page - Alerts', function () { + this.tags(['cloud_security_posture_findings_alerts']); + let findings: typeof pageObjects.findings; + let latestFindingsTable: typeof findings.latestFindingsTable; + let misconfigurationsFlyout: typeof findings.misconfigurationsFlyout; + + before(async () => { + findings = pageObjects.findings; + latestFindingsTable = findings.latestFindingsTable; + misconfigurationsFlyout = findings.misconfigurationsFlyout; + // Before we start any test we must wait for cloud_security_posture plugin to complete its initialization + await findings.waitForPluginInitialized(); + // Prepare mocked findings + await findings.index.remove(); + await findings.index.add(data); + }); + + after(async () => { + await findings.index.remove(); + await findings.detectionRuleApi.remove(); + }); + + beforeEach(async () => { + await findings.detectionRuleApi.remove(); + await findings.navigateToLatestFindingsPage(); + await retry.waitFor( + 'Findings table to be loaded', + async () => (await latestFindingsTable.getRowsCount()) === data.length + ); + pageObjects.header.waitUntilLoadingHasFinished(); + }); + + describe('Create detection rule', () => { + it('Creates a detection rule from the Take Action button and navigates to rule page', async () => { + await latestFindingsTable.openFlyoutAt(0); + await misconfigurationsFlyout.clickTakeActionCreateRuleButton(); + + expect( + await misconfigurationsFlyout.getVisibleText('csp:findings-flyout-alert-count') + ).to.be('0 alerts'); + + expect( + await misconfigurationsFlyout.getVisibleText('csp:findings-flyout-detection-rule-count') + ).to.be('1 detection rule'); + + const toastMessage = await (await findings.toastMessage()).getElement(); + expect(toastMessage).to.be.ok(); + + const toastMessageTitle = await toastMessage.findByTestSubject('csp:toast-success-title'); + expect(await toastMessageTitle.getVisibleText()).to.be(ruleName1); + + await (await findings.toastMessage()).clickToastMessageLink(); + + const rulePageTitle = await testSubjects.find('header-page-title'); + expect(await rulePageTitle.getVisibleText()).to.be(ruleName1); + }); + it('Creates a detection rule from the Alerts section and navigates to rule page', async () => { + await latestFindingsTable.openFlyoutAt(0); + const flyout = await misconfigurationsFlyout.getElement(); + + await ( + await flyout.findByTestSubject('csp:findings-flyout-create-detection-rule-link') + ).click(); + + expect( + await misconfigurationsFlyout.getVisibleText('csp:findings-flyout-alert-count') + ).to.be('0 alerts'); + + expect( + await misconfigurationsFlyout.getVisibleText('csp:findings-flyout-detection-rule-count') + ).to.be('1 detection rule'); + + const toastMessage = await (await findings.toastMessage()).getElement(); + expect(toastMessage).to.be.ok(); + + const toastMessageTitle = await toastMessage.findByTestSubject('csp:toast-success-title'); + expect(await toastMessageTitle.getVisibleText()).to.be(ruleName1); + + await (await findings.toastMessage()).clickToastMessageLink(); + + const rulePageTitle = await testSubjects.find('header-page-title'); + expect(await rulePageTitle.getVisibleText()).to.be(ruleName1); + }); + }); + describe('Rule details', () => { + it('The rule page contains the expected matching data', async () => { + await latestFindingsTable.openFlyoutAt(0); + await misconfigurationsFlyout.clickTakeActionCreateRuleButton(); + + await (await findings.toastMessage()).clickToastMessageLink(); + + const rulePageDescription = await testSubjects.find( + 'stepAboutRuleDetailsToggleDescriptionText' + ); + expect(await rulePageDescription.getVisibleText()).to.be(data[0].rule.rationale); + + const severity = await testSubjects.find('severity'); + expect(await severity.getVisibleText()).to.be('Low'); + + const referenceUrls = await testSubjects.find('urlsDescriptionReferenceLinkItem'); + expect(await referenceUrls.getVisibleText()).to.contain('https://elastic.co/rules/1.1'); + }); + }); + describe('Navigation', () => { + it('Clicking on count of Rules should navigate to the rules page with benchmark tags as a filter', async () => { + await latestFindingsTable.openFlyoutAt(0); + await misconfigurationsFlyout.clickTakeActionCreateRuleButton(); + const flyout = await misconfigurationsFlyout.getElement(); + await (await flyout.findByTestSubject('csp:findings-flyout-detection-rule-count')).click(); + + expect(await (await testSubjects.find('ruleName')).getVisibleText()).to.be(ruleName1); + }); + it('Clicking on count of Alerts should navigate to the alerts page', async () => { + await latestFindingsTable.openFlyoutAt(0); + await misconfigurationsFlyout.clickTakeActionCreateRuleButton(); + const flyout = await misconfigurationsFlyout.getElement(); + await (await flyout.findByTestSubject('csp:findings-flyout-alert-count')).click(); + + expect(await (await testSubjects.find('header-page-title')).getVisibleText()).to.be( + 'Alerts' + ); + }); + }); + }); +} diff --git a/x-pack/test/cloud_security_posture_functional/pages/index.ts b/x-pack/test/cloud_security_posture_functional/pages/index.ts index 1d30a3b27fda9..81e905ddaca35 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/index.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/index.ts @@ -12,6 +12,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { describe('Cloud Security Posture', function () { loadTestFile(require.resolve('./findings_onboarding')); loadTestFile(require.resolve('./findings')); + loadTestFile(require.resolve('./findings_alerts')); loadTestFile(require.resolve('./compliance_dashboard')); }); } From 7f82102d720b5dffb5dafe2fd48a8ba760b9b4ec Mon Sep 17 00:00:00 2001 From: Alex Szabo Date: Mon, 25 Sep 2023 18:49:20 +0200 Subject: [PATCH 05/12] [Ops] ES Serverless image verification pipeline (#166054) ## Summary Prepares the serverless FTR tests to be runnable with a custom ES image. (`--esServerlessImage` cli arg) Creates a pipeline for testing and promoting ES Serverless docker releases. The job can be triggered here: https://buildkite.com/elastic/kibana-elasticsearch-serverless-verify-and-promote The three main env variables it takes: - BUILDKITE_BRANCH: the kibana branch to test with (maybe not as important) - BUILDKITE_COMMIT: the kibana commit to test with - ES_SERVERLESS_IMAGE: the elasticsearch serverless image, or tag to use from this repo: `docker.elastic.co/elasticsearch-ci/elasticsearch-serverless` ## TODOS: - [x] set `latest_verified` with full img path as default - [x] ~~find other CLIs that might need the `esServerlessImage` argument (if the docker runner has multiple usages)~~ | I confused the `yarn es docker` with this, because I thought we only run ES serverless in a docker container, but `elasticsearch` can also be run in docker. - [x] set `latest-compatible` or similar flag in a manifest in gcs for Elastic's use-case - [ ] ensure we can only verify "forward" (ie.: to avoid a parameterization on old versions to set our pointers back) [on a second thought, this might be kept as a feature to roll back (if we should ever need that)] There are two confusing things I couldn't sort out just yet: #### Ambiguity in --esServerlessImage We can either have 2 CLI args: one for an image tag, one for an image repo/image url, or we can have one (like I have it now) and interpret that in the code, it can be either the image url, or the tag. It's more flexible, but it's two things in one. Is it ok this way, or is it too confusing? e.g.: ``` node scripts/functional_tests --esFrom serverless --esServerlessImage docker.elastic.co/elasticsearch-ci/elasticsearch-serverless:git-8fc8f941bd4d --bail --config x-pack/test_serverless/functional/test_suites/security/config.ts # or node scripts/functional_tests --esFrom serverless --esServerlessImage latest --bail --config x-pack/test_serverless/functional/test_suites/security/config.ts ``` #### Ambiguity in the default image path The published ES Serverless images will sit on this image path: `docker.elastic.co/elasticsearch-ci/elasticsearch-serverless`, however, our one exception is the `latest-verified` which we will be tagging under a different path, where we have write rights: `docker.elastic.co/kibana-ci/elasticsearch-serverless:latest-verified`. Is it okay, that by default, we're searching in the `elasticsearch-ci` images for any tags as parameters (after all, all the new images will be published there), however our grand default will ultimately be `docker.elastic.co/kibana-ci/elasticsearch-serverless:latest-verified`. ## Links Buildkite: https://buildkite.com/elastic/kibana-elasticsearch-serverless-verify-and-promote eg.: https://buildkite.com/elastic/kibana-elasticsearch-serverless-verify-and-promote/builds/24 Closes: https://github.com/elastic/kibana/issues/162931 --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../verify_es_serverless_image.yml | 58 ++++++++++++++ .../promote_es_serverless_image.sh | 75 +++++++++++++++++++ packages/kbn-dev-cli-runner/src/flags.ts | 2 + packages/kbn-dev-cli-runner/src/help.ts | 8 +- packages/kbn-dev-cli-runner/src/run.ts | 1 + .../kbn-es/src/cli_commands/serverless.ts | 16 ++-- packages/kbn-es/src/utils/docker.test.ts | 10 ++- packages/kbn-es/src/utils/docker.ts | 49 ++++++++---- packages/kbn-test/src/es/es_test_config.ts | 4 + packages/kbn-test/src/es/test_es_cluster.ts | 16 +++- .../functional_tests/lib/run_elasticsearch.ts | 28 ++++++- .../src/functional_tests/run_tests/cli.ts | 1 + .../functional_tests/run_tests/flags.test.ts | 1 + .../src/functional_tests/run_tests/flags.ts | 10 +++ 14 files changed, 246 insertions(+), 33 deletions(-) create mode 100644 .buildkite/pipelines/es_serverless/verify_es_serverless_image.yml create mode 100755 .buildkite/scripts/steps/es_serverless/promote_es_serverless_image.sh diff --git a/.buildkite/pipelines/es_serverless/verify_es_serverless_image.yml b/.buildkite/pipelines/es_serverless/verify_es_serverless_image.yml new file mode 100644 index 0000000000000..f6cb21abdb682 --- /dev/null +++ b/.buildkite/pipelines/es_serverless/verify_es_serverless_image.yml @@ -0,0 +1,58 @@ +# https://buildkite.com/elastic/kibana-elasticsearch-serverless-verify-and-promote/ +agents: + queue: kibana-default + +steps: + - label: "Annotate runtime parameters" + command: | + buildkite-agent annotate --context es-serverless-image --style info "ES Serverless image: $ES_SERVERLESS_IMAGE" + buildkite-agent annotate --context kibana-commit --style info "Kibana build hash: $BUILDKITE_BRANCH / $BUILDKITE_COMMIT" + + - group: "(:kibana: x :elastic:) Trigger Kibana Serverless suite" + if: "build.env('SKIP_VERIFICATION') != '1' && build.env('SKIP_VERIFICATION') != 'true'" + steps: + - label: "Pre-Build" + command: .buildkite/scripts/lifecycle/pre_build.sh + key: pre-build + timeout_in_minutes: 10 + agents: + queue: kibana-default + + - label: "Build Kibana Distribution and Plugins" + command: .buildkite/scripts/steps/build_kibana.sh + agents: + queue: n2-16-spot + key: build + depends_on: pre-build + if: "build.env('KIBANA_BUILD_ID') == null || build.env('KIBANA_BUILD_ID') == ''" + timeout_in_minutes: 60 + retry: + automatic: + - exit_status: '-1' + limit: 3 + + - label: "Pick Test Group Run Order" + command: .buildkite/scripts/steps/test/pick_test_group_run_order.sh + agents: + queue: kibana-default + env: + FTR_CONFIGS_SCRIPT: 'TEST_ES_SERVERLESS_IMAGE=$ES_SERVERLESS_IMAGE .buildkite/scripts/steps/test/ftr_configs.sh' + FTR_CONFIG_PATTERNS: '**/test_serverless/**' + LIMIT_CONFIG_TYPE: 'functional' + retry: + automatic: + - exit_status: '*' + limit: 1 + + - wait: ~ + + - label: ":arrow_up::elastic::arrow_up: Promote docker image" + command: .buildkite/scripts/steps/es_serverless/promote_es_serverless_image.sh $ES_SERVERLESS_IMAGE + + - wait: ~ + + - label: 'Post-Build' + command: .buildkite/scripts/lifecycle/post_build.sh + timeout_in_minutes: 10 + agents: + queue: kibana-default diff --git a/.buildkite/scripts/steps/es_serverless/promote_es_serverless_image.sh b/.buildkite/scripts/steps/es_serverless/promote_es_serverless_image.sh new file mode 100755 index 0000000000000..c6bf1738fe144 --- /dev/null +++ b/.buildkite/scripts/steps/es_serverless/promote_es_serverless_image.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +set -euo pipefail + +source .buildkite/scripts/common/util.sh + +BASE_ES_SERVERLESS_REPO=docker.elastic.co/elasticsearch-ci/elasticsearch-serverless +TARGET_IMAGE=docker.elastic.co/kibana-ci/elasticsearch-serverless:latest-verified + +ES_SERVERLESS_BUCKET=kibana-ci-es-serverless-images +MANIFEST_FILE_NAME=latest-verified.json + +SOURCE_IMAGE_OR_TAG=$1 +if [[ $SOURCE_IMAGE_OR_TAG =~ :[a-zA-Z_-]+$ ]]; then + # $SOURCE_IMAGE_OR_TAG was a full image + SOURCE_IMAGE=$SOURCE_IMAGE_OR_TAG +else + # $SOURCE_IMAGE_OR_TAG was an image tag + SOURCE_IMAGE="$BASE_ES_SERVERLESS_REPO:$SOURCE_IMAGE_OR_TAG" +fi + +echo "--- Promoting ${SOURCE_IMAGE_OR_TAG} to ':latest-verified'" + +echo "Re-tagging $SOURCE_IMAGE -> $TARGET_IMAGE" + +echo "$KIBANA_DOCKER_PASSWORD" | docker login -u "$KIBANA_DOCKER_USERNAME" --password-stdin docker.elastic.co +docker pull "$SOURCE_IMAGE" +docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE" +docker push "$TARGET_IMAGE" + +ORIG_IMG_DATA=$(docker inspect "$SOURCE_IMAGE") +ELASTIC_COMMIT_HASH=$(echo $ORIG_IMG_DATA | jq -r '.[].Config.Labels["org.opencontainers.image.revision"]') + +docker logout docker.elastic.co + +echo "Image push to $TARGET_IMAGE successful." +echo "Promotion successful! Henceforth, thou shall be named Sir $TARGET_IMAGE" + +MANIFEST_UPLOAD_PATH="Skipped" +if [[ "$UPLOAD_MANIFEST" =~ ^(1|true)$ && "$SOURCE_IMAGE_OR_TAG" =~ ^git-[0-9a-fA-F]{12}$ ]]; then + echo "--- Uploading latest-verified manifest to GCS" + cat << EOT >> $MANIFEST_FILE_NAME +{ + "build_url": "$BUILDKITE_BUILD_URL", + "kibana_commit": "$BUILDKITE_COMMIT", + "kibana_branch": "$BUILDKITE_BRANCH", + "elasticsearch_serverless_tag": "$SOURCE_IMAGE_OR_TAG", + "elasticsearch_serverless_image_url: "$SOURCE_IMAGE", + "elasticsearch_serverless_commit": "TODO: this currently can't be decided", + "elasticsearch_commit": "$ELASTIC_COMMIT_HASH", + "created_at": "`date`", + "timestamp": "`FORCE_COLOR=0 node -p 'Date.now()'`" +} +EOT + + gsutil -h "Cache-Control:no-cache, max-age=0, no-transform" \ + cp $MANIFEST_FILE_NAME "gs://$ES_SERVERLESS_BUCKET/$MANIFEST_FILE_NAME" + gsutil acl ch -u AllUsers:R "gs://$ES_SERVERLESS_BUCKET/$MANIFEST_FILE_NAME" + MANIFEST_UPLOAD_PATH="$MANIFEST_FILE_NAME" + +elif [[ "$UPLOAD_MANIFEST" =~ ^(1|true)$ ]]; then + echo "--- Skipping upload of latest-verified manifest to GCS, ES Serverless build tag is not pointing to a hash" +elif [[ "$SOURCE_IMAGE_OR_TAG" =~ ^git-[0-9a-fA-F]{12}$ ]]; then + echo "--- Skipping upload of latest-verified manifest to GCS, flag was not provided" +fi + +echo "--- Annotating build with info" +cat << EOT | buildkite-agent annotate --style "success" +

    Promotion successful!

    +
    New image: $TARGET_IMAGE +
    Source image: $SOURCE_IMAGE +
    Kibana commit: $BUILDKITE_COMMIT +
    Elasticsearch commit: $ELASTIC_COMMIT_HASH +
    Manifest file: $MANIFEST_UPLOAD_PATH +EOT diff --git a/packages/kbn-dev-cli-runner/src/flags.ts b/packages/kbn-dev-cli-runner/src/flags.ts index 595205c3e0333..d7b352333ae1b 100644 --- a/packages/kbn-dev-cli-runner/src/flags.ts +++ b/packages/kbn-dev-cli-runner/src/flags.ts @@ -27,6 +27,7 @@ export interface FlagOptions { allowUnexpected?: boolean; guessTypesForUnexpectedFlags?: boolean; help?: string; + examples?: string; alias?: { [key: string]: string | string[] }; boolean?: string[]; string?: string[]; @@ -47,6 +48,7 @@ export function mergeFlagOptions(global: FlagOptions = {}, local: FlagOptions = }, help: local.help, + examples: local.examples, allowUnexpected: !!(global.allowUnexpected || local.allowUnexpected), guessTypesForUnexpectedFlags: !!(global.allowUnexpected || local.allowUnexpected), diff --git a/packages/kbn-dev-cli-runner/src/help.ts b/packages/kbn-dev-cli-runner/src/help.ts index a7dc17aa43f17..f3e0e2c78e97f 100644 --- a/packages/kbn-dev-cli-runner/src/help.ts +++ b/packages/kbn-dev-cli-runner/src/help.ts @@ -36,11 +36,13 @@ export function getHelp({ usage, flagHelp, defaultLogLevel, + examples, }: { description?: string; usage?: string; flagHelp?: string; defaultLogLevel?: string; + examples?: string; }) { const optionHelp = joinAndTrimLines( dedent(flagHelp || ''), @@ -48,13 +50,17 @@ export function getHelp({ GLOBAL_FLAGS ); + const examplesHelp = examples ? joinAndTrimLines('Examples:', examples) : ''; + return ` ${dedent(usage || '') || DEFAULT_GLOBAL_USAGE} ${indent(dedent(description || 'Runs a dev task'), 2)} Options: - ${indent(optionHelp, 4)}\n\n`; + ${indent(optionHelp, 4)} +${examplesHelp ? `\n ${indent(examplesHelp, 4)}` : ''} +`; } export function getCommandLevelHelp({ diff --git a/packages/kbn-dev-cli-runner/src/run.ts b/packages/kbn-dev-cli-runner/src/run.ts index 08457caaebfd4..2ef90c2e2c27a 100644 --- a/packages/kbn-dev-cli-runner/src/run.ts +++ b/packages/kbn-dev-cli-runner/src/run.ts @@ -50,6 +50,7 @@ export async function run(fn: RunFn, options: RunOptions = {}) { usage: options.usage, flagHelp: options.flags?.help, defaultLogLevel: options.log?.defaultLevel, + examples: options.flags?.examples, }); if (flags.help) { diff --git a/packages/kbn-es/src/cli_commands/serverless.ts b/packages/kbn-es/src/cli_commands/serverless.ts index 7ee4f08fb94fe..c8b3018e6f669 100644 --- a/packages/kbn-es/src/cli_commands/serverless.ts +++ b/packages/kbn-es/src/cli_commands/serverless.ts @@ -13,9 +13,8 @@ import { getTimeReporter } from '@kbn/ci-stats-reporter'; import { Cluster } from '../cluster'; import { - SERVERLESS_REPO, - SERVERLESS_TAG, - SERVERLESS_IMG, + ES_SERVERLESS_REPO_ELASTICSEARCH, + ES_SERVERLESS_DEFAULT_IMAGE, DEFAULT_PORT, ServerlessOptions, } from '../utils'; @@ -28,9 +27,8 @@ export const serverless: Command = { return dedent` Options: - --tag Image tag of ES serverless to run from ${SERVERLESS_REPO} [default: ${SERVERLESS_TAG}] - --image Full path of ES serverless image to run, has precedence over tag. [default: ${SERVERLESS_IMG}] - + --tag Image tag of ES serverless to run from ${ES_SERVERLESS_REPO_ELASTICSEARCH} + --image Full path of ES serverless image to run, has precedence over tag. [default: ${ES_SERVERLESS_DEFAULT_IMAGE}] --background Start ES serverless without attaching to the first node's logs --basePath Path to the directory where the ES cluster will store data --clean Remove existing file system object store before running @@ -39,14 +37,14 @@ export const serverless: Command = { --ssl Enable HTTP SSL on the ES cluster --skipTeardown If this process exits, leave the ES cluster running in the background --waitForReady Wait for the ES cluster to be ready to serve requests - + -E Additional key=value settings to pass to ES -F Absolute paths for files to mount into containers Examples: - es serverless --tag git-fec36430fba2-x86_64 - es serverless --image docker.elastic.co/repo:tag + es serverless --tag git-fec36430fba2-x86_64 # loads ${ES_SERVERLESS_REPO_ELASTICSEARCH}:git-fec36430fba2-x86_64 + es serverless --image docker.elastic.co/kibana-ci/elasticsearch-serverless:latest-verified `; }, run: async (defaults = {}) => { diff --git a/packages/kbn-es/src/utils/docker.test.ts b/packages/kbn-es/src/utils/docker.test.ts index d48cddd6fdb6d..08edc2a17521d 100644 --- a/packages/kbn-es/src/utils/docker.test.ts +++ b/packages/kbn-es/src/utils/docker.test.ts @@ -23,7 +23,7 @@ import { runDockerContainer, runServerlessCluster, runServerlessEsNode, - SERVERLESS_IMG, + ES_SERVERLESS_DEFAULT_IMAGE, setupServerlessVolumes, stopServerlessCluster, teardownServerlessClusterSync, @@ -451,7 +451,7 @@ describe('runServerlessEsNode()', () => { const node = { params: ['--env', 'foo=bar', '--volume', 'foo/bar'], name: 'es01', - image: SERVERLESS_IMG, + image: ES_SERVERLESS_DEFAULT_IMAGE, }; test('should call the correct Docker command', async () => { @@ -462,7 +462,7 @@ describe('runServerlessEsNode()', () => { expect(execa.mock.calls[0][0]).toEqual('docker'); expect(execa.mock.calls[0][1]).toEqual( expect.arrayContaining([ - SERVERLESS_IMG, + ES_SERVERLESS_DEFAULT_IMAGE, ...node.params, '--name', node.name, @@ -530,7 +530,9 @@ describe('teardownServerlessClusterSync()', () => { teardownServerlessClusterSync(log, defaultOptions); expect(execa.commandSync.mock.calls).toHaveLength(2); - expect(execa.commandSync.mock.calls[0][0]).toEqual(expect.stringContaining(SERVERLESS_IMG)); + expect(execa.commandSync.mock.calls[0][0]).toEqual( + expect.stringContaining(ES_SERVERLESS_DEFAULT_IMAGE) + ); expect(execa.commandSync.mock.calls[1][0]).toEqual(`docker kill ${nodes.join(' ')}`); }); diff --git a/packages/kbn-es/src/utils/docker.ts b/packages/kbn-es/src/utils/docker.ts index 00a1d7ce9dc54..5ed22e094e6f8 100644 --- a/packages/kbn-es/src/utils/docker.ts +++ b/packages/kbn-es/src/utils/docker.ts @@ -38,9 +38,12 @@ import { import { SYSTEM_INDICES_SUPERUSER } from './native_realm'; import { waitUntilClusterReady } from './wait_until_cluster_ready'; -interface BaseOptions { - tag?: string; +interface ImageOptions { image?: string; + tag?: string; +} + +interface BaseOptions extends ImageOptions { port?: number; ssl?: boolean; /** Kill running cluster before starting a new cluster */ @@ -106,9 +109,10 @@ export const DOCKER_REPO = `${DOCKER_REGISTRY}/elasticsearch/elasticsearch`; export const DOCKER_TAG = `${pkg.version}-SNAPSHOT`; export const DOCKER_IMG = `${DOCKER_REPO}:${DOCKER_TAG}`; -export const SERVERLESS_REPO = `${DOCKER_REGISTRY}/elasticsearch-ci/elasticsearch-serverless`; -export const SERVERLESS_TAG = 'latest'; -export const SERVERLESS_IMG = `${SERVERLESS_REPO}:${SERVERLESS_TAG}`; +export const ES_SERVERLESS_REPO_KIBANA = `${DOCKER_REGISTRY}/kibana-ci/elasticsearch-serverless`; +export const ES_SERVERLESS_REPO_ELASTICSEARCH = `${DOCKER_REGISTRY}/elasticsearch-ci/elasticsearch-serverless`; +export const ES_SERVERLESS_LATEST_VERIFIED_TAG = 'latest-verified'; +export const ES_SERVERLESS_DEFAULT_IMAGE = `${ES_SERVERLESS_REPO_KIBANA}:${ES_SERVERLESS_LATEST_VERIFIED_TAG}`; // See for default cluster settings // https://github.com/elastic/elasticsearch-serverless/blob/main/serverless-build-tools/src/main/kotlin/elasticsearch.serverless-run.gradle.kts @@ -275,7 +279,12 @@ export function resolveDockerImage({ image, repo, defaultImg, -}: (ServerlessOptions | DockerOptions) & { repo: string; defaultImg: string }) { +}: { + tag?: string; + image?: string; + repo: string; + defaultImg: string; +}) { if (image) { if (!image.includes(DOCKER_REGISTRY)) { throw createCliError( @@ -525,11 +534,12 @@ export async function setupServerlessVolumes(log: ToolingLog, options: Serverles /** * Resolve the Serverless ES image based on defaults and CLI options */ -function getServerlessImage(options: ServerlessOptions) { +function getServerlessImage({ image, tag }: ImageOptions) { return resolveDockerImage({ - ...options, - repo: SERVERLESS_REPO, - defaultImg: SERVERLESS_IMG, + image, + tag, + repo: ES_SERVERLESS_REPO_ELASTICSEARCH, + defaultImg: ES_SERVERLESS_DEFAULT_IMAGE, }); } @@ -573,7 +583,10 @@ function getESClient(clientOptions: ClientOptions): Client { * Runs an ES Serverless Cluster through Docker */ export async function runServerlessCluster(log: ToolingLog, options: ServerlessOptions) { - const image = getServerlessImage(options); + const image = getServerlessImage({ + image: options.image, + tag: options.tag, + }); await setupDocker({ log, image, options }); const volumeCmd = await setupServerlessVolumes(log, options); @@ -686,8 +699,13 @@ export function teardownServerlessClusterSync(log: ToolingLog, options: Serverle /** * Resolve the Elasticsearch image based on defaults and CLI options */ -function getDockerImage(options: DockerOptions) { - return resolveDockerImage({ ...options, repo: DOCKER_REPO, defaultImg: DOCKER_IMG }); +function getDockerImage({ image, tag }: ImageOptions) { + return resolveDockerImage({ + image, + tag, + repo: DOCKER_REPO, + defaultImg: DOCKER_IMG, + }); } /** @@ -713,7 +731,10 @@ export async function runDockerContainer(log: ToolingLog, options: DockerOptions let image; if (!options.dockerCmd) { - image = getDockerImage(options); + image = getDockerImage({ + image: options.image, + tag: options.tag, + }); await setupDocker({ log, image, options }); } diff --git a/packages/kbn-test/src/es/es_test_config.ts b/packages/kbn-test/src/es/es_test_config.ts index c31728b0fd7d8..26827558b7bd3 100644 --- a/packages/kbn-test/src/es/es_test_config.ts +++ b/packages/kbn-test/src/es/es_test_config.ts @@ -27,6 +27,10 @@ class EsTestConfig { return process.env.TEST_ES_FROM || 'snapshot'; } + getESServerlessImage() { + return process.env.TEST_ES_SERVERLESS_IMAGE; + } + getTransportPort() { return process.env.TEST_ES_TRANSPORT_PORT || '9300-9400'; } diff --git a/packages/kbn-test/src/es/test_es_cluster.ts b/packages/kbn-test/src/es/test_es_cluster.ts index 3cd90d21d1d91..3c63960bdc0e5 100644 --- a/packages/kbn-test/src/es/test_es_cluster.ts +++ b/packages/kbn-test/src/es/test_es_cluster.ts @@ -70,6 +70,10 @@ export interface CreateTestEsClusterOptions { */ esArgs?: string[]; esFrom?: string; + esServerlessOptions?: { + image?: string; + tag?: string; + }; esJavaOpts?: string; /** * License to run your cluster under. Keep in mind that a `trial` license @@ -164,6 +168,7 @@ export function createTestEsCluster< writeLogsToPath, basePath = Path.resolve(REPO_ROOT, '.es'), esFrom = esTestConfig.getBuildFrom(), + esServerlessOptions, dataArchive, nodes = [{ name: 'node-01' }], esArgs: customEsArgs = [], @@ -236,9 +241,11 @@ export function createTestEsCluster< } else if (esFrom === 'snapshot') { installPath = (await firstNode.installSnapshot(config)).installPath; } else if (esFrom === 'serverless') { - return await firstNode.runServerless({ + await firstNode.runServerless({ basePath, esArgs: customEsArgs, + image: esServerlessOptions?.image, + tag: esServerlessOptions?.tag, port, clean: true, background: true, @@ -247,6 +254,7 @@ export function createTestEsCluster< kill: true, // likely don't need this but avoids any issues where the ESS cluster wasn't cleaned up waitForReady: true, }); + return; } else if (Path.isAbsolute(esFrom)) { installPath = esFrom; } else { @@ -275,9 +283,9 @@ export function createTestEsCluster< }); } - nodeStartPromises.push(async () => { + nodeStartPromises.push(() => { log.info(`[es] starting node ${node.name} on port ${nodePort}`); - return await this.nodes[i].start(installPath, { + return this.nodes[i].start(installPath, { password: config.password, esArgs: assignArgs(esArgs, overriddenArgs), esJavaOpts, @@ -292,7 +300,7 @@ export function createTestEsCluster< }); } - await Promise.all(extractDirectoryPromises.map(async (extract) => await extract())); + await Promise.all(extractDirectoryPromises.map((extract) => extract())); for (const start of nodeStartPromises) { await start(); } diff --git a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts index f9c83161b521b..d298a1c1abaa4 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts @@ -12,11 +12,12 @@ import getPort from 'get-port'; import { REPO_ROOT } from '@kbn/repo-info'; import type { ArtifactLicense } from '@kbn/es'; import type { Config } from '../../functional_test_runner'; -import { createTestEsCluster } from '../../es'; +import { createTestEsCluster, esTestConfig } from '../../es'; interface RunElasticsearchOptions { log: ToolingLog; esFrom?: string; + esServerlessImage?: string; config: Config; onEarlyExit?: (msg: string) => void; logsDir?: string; @@ -32,6 +33,7 @@ type EsConfig = ReturnType; function getEsConfig({ config, esFrom = config.get('esTestCluster.from'), + esServerlessImage, }: RunElasticsearchOptions) { const ssl = !!config.get('esTestCluster.ssl'); const license: ArtifactLicense = config.get('esTestCluster.license'); @@ -50,6 +52,8 @@ function getEsConfig({ const serverless: boolean = config.get('serverless'); const files: string[] | undefined = config.get('esTestCluster.files'); + const esServerlessOptions = getESServerlessOptions(esServerlessImage, config); + return { ssl, license, @@ -57,6 +61,7 @@ function getEsConfig({ esJavaOpts, isSecurityEnabled, esFrom, + esServerlessOptions, port, password, dataArchive, @@ -129,6 +134,7 @@ async function startEsNode({ clusterName: `cluster-${name}`, esArgs: config.esArgs, esFrom: config.esFrom, + esServerlessOptions: config.esServerlessOptions, esJavaOpts: config.esJavaOpts, license: config.license, password: config.password, @@ -153,3 +159,23 @@ async function startEsNode({ return cluster; } + +function getESServerlessOptions(esServerlessImageFromArg: string | undefined, config: Config) { + const esServerlessImageUrlOrTag = + esServerlessImageFromArg || + esTestConfig.getESServerlessImage() || + (config.has('esTestCluster.esServerlessImage') && + config.get('esTestCluster.esServerlessImage')); + + if (esServerlessImageUrlOrTag) { + if (esServerlessImageUrlOrTag.includes(':')) { + return { + image: esServerlessImageUrlOrTag, + }; + } else { + return { + tag: esServerlessImageUrlOrTag, + }; + } + } +} diff --git a/packages/kbn-test/src/functional_tests/run_tests/cli.ts b/packages/kbn-test/src/functional_tests/run_tests/cli.ts index 19a003dd973cf..40a711ca9f6c2 100644 --- a/packages/kbn-test/src/functional_tests/run_tests/cli.ts +++ b/packages/kbn-test/src/functional_tests/run_tests/cli.ts @@ -26,6 +26,7 @@ export function runTestsCli() { { description: `Run Functional Tests`, usage: ` + Usage: node scripts/functional_tests --help node scripts/functional_tests [--config [--config ...]] node scripts/functional_tests [options] [-- --] diff --git a/packages/kbn-test/src/functional_tests/run_tests/flags.test.ts b/packages/kbn-test/src/functional_tests/run_tests/flags.test.ts index 0c9dae3a25794..77399605b29f4 100644 --- a/packages/kbn-test/src/functional_tests/run_tests/flags.test.ts +++ b/packages/kbn-test/src/functional_tests/run_tests/flags.test.ts @@ -42,6 +42,7 @@ describe('parse runTest flags', () => { ], "dryRun": false, "esFrom": undefined, + "esServerlessImage": undefined, "esVersion": , "grep": undefined, "installDir": undefined, diff --git a/packages/kbn-test/src/functional_tests/run_tests/flags.ts b/packages/kbn-test/src/functional_tests/run_tests/flags.ts index f4dd6beb26e80..3ba86999d3802 100644 --- a/packages/kbn-test/src/functional_tests/run_tests/flags.ts +++ b/packages/kbn-test/src/functional_tests/run_tests/flags.ts @@ -23,6 +23,7 @@ export const FLAG_OPTIONS: FlagOptions = { 'config', 'journey', 'esFrom', + 'esServerlessImage', 'kibana-install-dir', 'grep', 'include-tag', @@ -37,6 +38,7 @@ export const FLAG_OPTIONS: FlagOptions = { --config Define a FTR config that should be executed. Can be specified multiple times --journey Define a Journey that should be executed. Can be specified multiple times --esFrom Build Elasticsearch from source or run snapshot or serverless. Default: $TEST_ES_FROM or "snapshot" + --esServerlessImage When 'esFrom' is "serverless", this argument will be interpreted either as a tag within the ES Serverless repo, OR a full docker image path. --include-tag Tags that suites must include to be run, can be included multiple times --exclude-tag Tags that suites must NOT include to be run, can be included multiple times --include Files that must included to be run, can be included multiple times @@ -50,6 +52,13 @@ export const FLAG_OPTIONS: FlagOptions = { --updateSnapshots Replace inline and file snapshots with whatever is generated from the test --updateAll, -u Replace both baseline screenshots and snapshots `, + examples: ` +Run the latest verified, kibana-compatible ES Serverless image: + node scripts/functional_tests --config ./config.ts --esFrom serverless --esServerlessImage docker.elastic.co/kibana-ci/elasticsearch-serverless:latest-verified + +Run with a specific ES Serverless tag from the docker.elastic.co/elasticsearch-ci/elasticsearch-serverless repo: + node scripts/functional_tests --config ./config.ts --esFrom serverless --esServerlessImage git-fec36430fba2 + `, }; export function parseFlags(flags: FlagsReader) { @@ -75,6 +84,7 @@ export function parseFlags(flags: FlagsReader) { ? Path.resolve(REPO_ROOT, 'data/ftr_servers_logs', uuidV4()) : undefined, esFrom: flags.enum('esFrom', ['snapshot', 'source', 'serverless']), + esServerlessImage: flags.string('esServerlessImage'), installDir: flags.path('kibana-install-dir'), grep: flags.string('grep'), suiteTags: { From 5b24da796d3a992cd0e3eeb0262bd37564ba995b Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Mon, 25 Sep 2023 13:09:03 -0400 Subject: [PATCH 06/12] fix(slo): date range filter format (#166989) --- .../apm_transaction_duration.test.ts.snap | 16 ++++++++-------- .../apm_transaction_error_rate.test.ts.snap | 16 ++++++++-------- .../__snapshots__/histogram.test.ts.snap | 6 +++--- .../__snapshots__/kql_custom.test.ts.snap | 6 +++--- .../__snapshots__/metric_custom.test.ts.snap | 6 +++--- .../apm_transaction_duration.ts | 2 +- .../apm_transaction_error_rate.ts | 2 +- .../slo/transform_generators/histogram.ts | 2 +- .../slo/transform_generators/kql_custom.ts | 2 +- .../slo/transform_generators/metric_custom.ts | 2 +- 10 files changed, 30 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_duration.test.ts.snap b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_duration.test.ts.snap index 5f828ee51b625..aea9ba75f2863 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_duration.test.ts.snap +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_duration.test.ts.snap @@ -24,7 +24,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -130,7 +130,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -163,7 +163,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -277,7 +277,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -391,7 +391,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -505,7 +505,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -765,7 +765,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -1039,7 +1039,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_error_rate.test.ts.snap b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_error_rate.test.ts.snap index dc9278511a864..b7c4ebf0f6b8c 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_error_rate.test.ts.snap +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/apm_transaction_error_rate.test.ts.snap @@ -20,7 +20,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -122,7 +122,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -151,7 +151,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -261,7 +261,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -371,7 +371,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -481,7 +481,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -730,7 +730,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -993,7 +993,7 @@ Object { Object { "range": Object { "@timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/histogram.test.ts.snap b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/histogram.test.ts.snap index ee4001303fec5..ad100c4662fe9 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/histogram.test.ts.snap +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/histogram.test.ts.snap @@ -51,7 +51,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -232,7 +232,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -488,7 +488,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/kql_custom.test.ts.snap b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/kql_custom.test.ts.snap index 126437173f84a..3297a8c513821 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/kql_custom.test.ts.snap +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/kql_custom.test.ts.snap @@ -92,7 +92,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -247,7 +247,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -477,7 +477,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/metric_custom.test.ts.snap b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/metric_custom.test.ts.snap index ced0801d859d4..362b1d4a9e01e 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/metric_custom.test.ts.snap +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/__snapshots__/metric_custom.test.ts.snap @@ -63,7 +63,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -256,7 +256,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, @@ -524,7 +524,7 @@ Object { Object { "range": Object { "log_timestamp": Object { - "gte": "now-7d", + "gte": "now-7d/d", }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_duration.ts b/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_duration.ts index 3dd6469a7f2c5..171a5ca15e2de 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_duration.ts +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_duration.ts @@ -72,7 +72,7 @@ export class ApmTransactionDurationTransformGenerator extends TransformGenerator { range: { '@timestamp': { - gte: `now-${slo.timeWindow.duration.format()}`, + gte: `now-${slo.timeWindow.duration.format()}/d`, }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_error_rate.ts b/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_error_rate.ts index 3818111c70df2..d038876f5ee9d 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_error_rate.ts +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/apm_transaction_error_rate.ts @@ -72,7 +72,7 @@ export class ApmTransactionErrorRateTransformGenerator extends TransformGenerato { range: { '@timestamp': { - gte: `now-${slo.timeWindow.duration.format()}`, + gte: `now-${slo.timeWindow.duration.format()}/d`, }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/histogram.ts b/x-pack/plugins/observability/server/services/slo/transform_generators/histogram.ts index 654f8d67a3673..844703003257a 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/histogram.ts +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/histogram.ts @@ -54,7 +54,7 @@ export class HistogramTransformGenerator extends TransformGenerator { { range: { [indicator.params.timestampField]: { - gte: `now-${slo.timeWindow.duration.format()}`, + gte: `now-${slo.timeWindow.duration.format()}/d`, }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/kql_custom.ts b/x-pack/plugins/observability/server/services/slo/transform_generators/kql_custom.ts index 8321a0cb7172e..02c7757ec1362 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/kql_custom.ts +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/kql_custom.ts @@ -49,7 +49,7 @@ export class KQLCustomTransformGenerator extends TransformGenerator { { range: { [indicator.params.timestampField]: { - gte: `now-${slo.timeWindow.duration.format()}`, + gte: `now-${slo.timeWindow.duration.format()}/d`, }, }, }, diff --git a/x-pack/plugins/observability/server/services/slo/transform_generators/metric_custom.ts b/x-pack/plugins/observability/server/services/slo/transform_generators/metric_custom.ts index 52209533f828c..063e6fbe1e3dc 100644 --- a/x-pack/plugins/observability/server/services/slo/transform_generators/metric_custom.ts +++ b/x-pack/plugins/observability/server/services/slo/transform_generators/metric_custom.ts @@ -52,7 +52,7 @@ export class MetricCustomTransformGenerator extends TransformGenerator { { range: { [indicator.params.timestampField]: { - gte: `now-${slo.timeWindow.duration.format()}`, + gte: `now-${slo.timeWindow.duration.format()}/d`, }, }, }, From 93ce98831a900cc7f7cd1e91ac6f51bc57a67006 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 25 Sep 2023 20:38:22 +0300 Subject: [PATCH 07/12] [ES|QL] Hides system indices (#166909) ## Summary Closes https://github.com/elastic/kibana/issues/166874 Hides indices starting with . (and considered as system from the autocomplete) image Followed the exact pattern that the dataview management page is using. --- .../kbn-text-based-editor/src/helpers.test.ts | 31 +++++++++++++++++-- packages/kbn-text-based-editor/src/helpers.ts | 10 ++++++ .../src/text_based_languages_editor.tsx | 8 ++--- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/kbn-text-based-editor/src/helpers.test.ts b/packages/kbn-text-based-editor/src/helpers.test.ts index 5f1546ccc138e..f7b100419b2c7 100644 --- a/packages/kbn-text-based-editor/src/helpers.test.ts +++ b/packages/kbn-text-based-editor/src/helpers.test.ts @@ -5,8 +5,14 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - -import { parseErrors, parseWarning, getInlineEditorText, getWrappedInPipesCode } from './helpers'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { + parseErrors, + parseWarning, + getInlineEditorText, + getWrappedInPipesCode, + getIndicesForAutocomplete, +} from './helpers'; describe('helpers', function () { describe('parseErrors', function () { @@ -159,4 +165,25 @@ describe('helpers', function () { expect(code).toEqual('FROM index1 | keep field1, field2 | order field1'); }); }); + + describe('getIndicesForAutocomplete', function () { + it('should not return system indices', async function () { + const dataViewsMock = dataViewPluginMocks.createStartContract(); + const updatedDataViewsMock = { + ...dataViewsMock, + getIndices: jest.fn().mockResolvedValue([ + { + name: '.system1', + title: 'system1', + }, + { + name: 'logs', + title: 'logs', + }, + ]), + }; + const indices = await getIndicesForAutocomplete(updatedDataViewsMock); + expect(indices).toStrictEqual(['logs']); + }); + }); }); diff --git a/packages/kbn-text-based-editor/src/helpers.ts b/packages/kbn-text-based-editor/src/helpers.ts index 6b4e99f6e3093..8932276695117 100644 --- a/packages/kbn-text-based-editor/src/helpers.ts +++ b/packages/kbn-text-based-editor/src/helpers.ts @@ -10,6 +10,7 @@ import { useRef } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; import { monaco } from '@kbn/monaco'; import { i18n } from '@kbn/i18n'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; export interface MonacoError { message: string; @@ -172,3 +173,12 @@ export const getWrappedInPipesCode = (code: string, isWrapped: boolean): string }); return codeNoLines.join(isWrapped ? ' | ' : '\n| '); }; + +export const getIndicesForAutocomplete = async (dataViews: DataViewsPublicPluginStart) => { + const indices = await dataViews.getIndices({ + showAllIndices: false, + pattern: '*', + isRollupIndex: () => false, + }); + return indices.filter((index) => !index.name.startsWith('.')).map((i) => i.name); +}; diff --git a/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx b/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx index fc86f7ca1cac6..b1f1708262743 100644 --- a/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx +++ b/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx @@ -56,6 +56,7 @@ import { getDocumentationSections, MonacoError, getWrappedInPipesCode, + getIndicesForAutocomplete, } from './helpers'; import { EditorFooter } from './editor_footer'; import { ResizableButton } from './resizable_button'; @@ -371,12 +372,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ const getSourceIdentifiers: ESQLCustomAutocompleteCallbacks['getSourceIdentifiers'] = useCallback(async () => { - const indices = await dataViews.getIndices({ - showAllIndices: false, - pattern: '*', - isRollupIndex: () => false, - }); - return indices.map((i) => i.name); + return await getIndicesForAutocomplete(dataViews); }, [dataViews]); const getFieldsIdentifiers: ESQLCustomAutocompleteCallbacks['getFieldsIdentifiers'] = useCallback( From 3f03264dc024a0e2abdd610c774048df861d6c38 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Mon, 25 Sep 2023 13:46:35 -0400 Subject: [PATCH 08/12] [Security Solution][Endpoint] Refactor Cypress `login` task and ensure consistent use of users across ESS and Serverless tests (#166958) ## Summary - Cypress `login` task refactored: - `login(user?)` : logs use in using the default `user` or one of the users supported by security solution and endpoint management tests - `login.with(username, password)` : Logs a user in by using `username` and `password` - `login.withCustomRole(role)` : creates the provided `role`, creates a user for it by the same role name and logs in with it - The Cypress process for loading users into Kibana only applies to non-serverless (at the moment). For serverless, it only validates that the `username` being used is one of the approved user names that applies to serverless - FYI: the creation/availability of serverless roles/users for testing is an ongoing effort by the kibana ops team - New generic `RoleAndUserLoader` class. Is initialized with an map of `Roles` and provide a standard interface for loading them. - A sub-class (`EndpointSecurityTestRolesLoader`) was also created for the endpoint security test users, which uses the existing set of role definitions - The `resolver_generator_script` was also updated to use the new `EndpointSecurityTestRolesLoader` class for handling the `--rbacUser` argument --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../management/cypress/common/constants.ts | 21 ++ .../public/management/cypress/cypress.d.ts | 20 +- .../artifact_tabs_in_policy_details.cy.ts | 15 +- .../e2e/artifacts/artifacts_mocked_data.cy.ts | 15 +- .../e2e/automated_response_actions/form.cy.ts | 12 +- .../no_license.cy.ts | 4 +- ...dpoint_list_with_security_essentials.cy.ts | 4 +- .../serverless/feature_access/complete.cy.ts | 4 +- .../complete_with_endpoint.cy.ts | 4 +- .../feature_access/essentials.cy.ts | 4 +- .../essentials_with_endpoint.cy.ts | 4 +- ...icy_details_with_security_essentials.cy.ts | 4 +- .../roles/complete_with_endpoint_roles.cy.ts | 20 +- .../essentials_with_endpoint.roles.cy.ts | 22 +- .../role_with_artifact_read_privilege.ts | 30 ++ .../cypress/support/data_loaders.ts | 116 ++++++- .../public/management/cypress/tasks/login.ts | 295 +++++------------- .../cypress/tasks/login_serverless.ts | 103 ------ .../public/management/cypress/tsconfig.json | 1 + .../public/management/cypress/types.ts | 10 + .../scripts/endpoint/common/constants.ts | 5 + .../endpoint/common/role_and_user_loader.ts | 175 +++++++++++ .../endpoint/common/roles_users/index.ts | 139 +++++++++ .../common/roles_users/rule_author.ts | 36 +++ .../endpoint/common/roles_users/t3_analyst.ts | 39 +++ .../endpoint/resolver_generator_script.ts | 73 +---- 26 files changed, 714 insertions(+), 461 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/cypress/common/constants.ts create mode 100644 x-pack/plugins/security_solution/public/management/cypress/fixtures/role_with_artifact_read_privilege.ts delete mode 100644 x-pack/plugins/security_solution/public/management/cypress/tasks/login_serverless.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/role_and_user_loader.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/index.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/rule_author.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/t3_analyst.ts diff --git a/x-pack/plugins/security_solution/public/management/cypress/common/constants.ts b/x-pack/plugins/security_solution/public/management/cypress/common/constants.ts new file mode 100644 index 0000000000000..41f08f438e3f8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/common/constants.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EndpointSecurityRoleNames } from '../../../../scripts/endpoint/common/roles_users'; + +export type KibanaKnownUserAccounts = keyof typeof KIBANA_KNOWN_DEFAULT_ACCOUNTS; + +export type SecurityTestUser = EndpointSecurityRoleNames | KibanaKnownUserAccounts; + +/** + * List of kibana system accounts + */ +export const KIBANA_KNOWN_DEFAULT_ACCOUNTS = { + elastic: 'elastic', + elastic_serverless: 'elastic_serverless', + system_indices_superuser: 'system_indices_superuser', +} as const; diff --git a/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts b/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts index 4d84dc88af9c9..a4037e32632a5 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts @@ -17,7 +17,12 @@ import type { HostPolicyResponse, LogsEndpointActionResponse, } from '../../../common/endpoint/types'; -import type { IndexEndpointHostsCyTaskOptions, HostActionResponse } from './types'; +import type { + HostActionResponse, + IndexEndpointHostsCyTaskOptions, + LoadUserAndRoleCyTaskOptions, + CreateUserAndRoleCyTaskOptions, +} from './types'; import type { DeleteIndexedFleetEndpointPoliciesResponse, IndexedFleetEndpointPolicyResponse, @@ -32,6 +37,7 @@ import type { DeletedIndexedEndpointRuleAlerts, IndexedEndpointRuleAlerts, } from '../../../common/endpoint/data_loaders/index_endpoint_rule_alerts'; +import type { LoadedRoleAndUser } from '../../../scripts/endpoint/common/role_and_user_loader'; declare global { namespace Cypress { @@ -185,6 +191,18 @@ declare global { arg: { hostname: string; path: string; password?: string }, options?: Partial ): Chainable; + + task( + name: 'loadUserAndRole', + arg: LoadUserAndRoleCyTaskOptions, + options?: Partial + ): Chainable; + + task( + name: 'createUserAndRole', + arg: CreateUserAndRoleCyTaskOptions, + options?: Partial + ): Chainable; } } } diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifact_tabs_in_policy_details.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifact_tabs_in_policy_details.cy.ts index 39ebae1825365..8610ac1fc3ba5 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifact_tabs_in_policy_details.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifact_tabs_in_policy_details.cy.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { getRoleWithArtifactReadPrivilege } from '../../fixtures/role_with_artifact_read_privilege'; import { getEndpointSecurityPolicyManager } from '../../../../../scripts/endpoint/common/roles_users/endpoint_security_policy_manager'; import { getArtifactsListTestsData } from '../../fixtures/artifacts_page'; import { visitPolicyDetailsPage } from '../../screens/policy_details'; @@ -16,27 +17,21 @@ import { yieldFirstPolicyID, } from '../../tasks/artifacts'; import { loadEndpointDataForEventFiltersIfNeeded } from '../../tasks/load_endpoint_data'; -import { - getRoleWithArtifactReadPrivilege, - login, - loginWithCustomRole, - loginWithRole, - ROLE, -} from '../../tasks/login'; +import { login, ROLE } from '../../tasks/login'; import { performUserActions } from '../../tasks/perform_user_actions'; const loginWithPrivilegeAll = () => { - loginWithRole(ROLE.endpoint_security_policy_manager); + login(ROLE.endpoint_policy_manager); }; const loginWithPrivilegeRead = (privilegePrefix: string) => { const roleWithArtifactReadPrivilege = getRoleWithArtifactReadPrivilege(privilegePrefix); - loginWithCustomRole('roleWithArtifactReadPrivilege', roleWithArtifactReadPrivilege); + login.withCustomRole({ name: 'roleWithArtifactReadPrivilege', ...roleWithArtifactReadPrivilege }); }; const loginWithPrivilegeNone = (privilegePrefix: string) => { const roleWithoutArtifactPrivilege = getRoleWithoutArtifactPrivilege(privilegePrefix); - loginWithCustomRole('roleWithoutArtifactPrivilege', roleWithoutArtifactPrivilege); + login.withCustomRole({ name: 'roleWithoutArtifactPrivilege', ...roleWithoutArtifactPrivilege }); }; const getRoleWithoutArtifactPrivilege = (privilegePrefix: string) => { diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifacts_mocked_data.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifacts_mocked_data.cy.ts index 8f575282ad3f7..86cd86dd797b7 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifacts_mocked_data.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/artifacts/artifacts_mocked_data.cy.ts @@ -5,13 +5,8 @@ * 2.0. */ -import { - getRoleWithArtifactReadPrivilege, - login, - loginWithCustomRole, - loginWithRole, - ROLE, -} from '../../tasks/login'; +import { getRoleWithArtifactReadPrivilege } from '../../fixtures/role_with_artifact_read_privilege'; +import { login, ROLE } from '../../tasks/login'; import { loadPage } from '../../tasks/common'; import { getArtifactsListTestsData } from '../../fixtures/artifacts_page'; @@ -20,18 +15,18 @@ import { performUserActions } from '../../tasks/perform_user_actions'; import { loadEndpointDataForEventFiltersIfNeeded } from '../../tasks/load_endpoint_data'; const loginWithWriteAccess = (url: string) => { - loginWithRole(ROLE.endpoint_security_policy_manager); + login(ROLE.endpoint_policy_manager); loadPage(url); }; const loginWithReadAccess = (privilegePrefix: string, url: string) => { const roleWithArtifactReadPrivilege = getRoleWithArtifactReadPrivilege(privilegePrefix); - loginWithCustomRole('roleWithArtifactReadPrivilege', roleWithArtifactReadPrivilege); + login.withCustomRole({ name: 'roleWithArtifactReadPrivilege', ...roleWithArtifactReadPrivilege }); loadPage(url); }; const loginWithoutAccess = (url: string) => { - loginWithRole(ROLE.t1_analyst); + login(ROLE.t1_analyst); loadPage(url); }; diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/form.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/form.cy.ts index 84f6903c35a9b..eb5bae8624475 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/form.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/form.cy.ts @@ -16,12 +16,12 @@ import { } from '../../tasks/response_actions'; import { cleanupRule, generateRandomStringName, loadRule } from '../../tasks/api_fixtures'; import { RESPONSE_ACTION_TYPES } from '../../../../../common/api/detection_engine'; -import { loginWithRole, ROLE } from '../../tasks/login'; +import { login, ROLE } from '../../tasks/login'; describe('Form', { tags: '@ess' }, () => { describe('User with no access can not create an endpoint response action', () => { before(() => { - loginWithRole(ROLE.endpoint_response_actions_no_access); + login(ROLE.endpoint_response_actions_no_access); }); it('no endpoint response action option during rule creation', () => { @@ -36,7 +36,7 @@ describe('Form', { tags: '@ess' }, () => { const [ruleName, ruleDescription] = generateRandomStringName(2); before(() => { - loginWithRole(ROLE.endpoint_response_actions_access); + login(ROLE.endpoint_response_actions_access); }); after(() => { cleanupRule(ruleId); @@ -94,7 +94,7 @@ describe('Form', { tags: '@ess' }, () => { }); }); beforeEach(() => { - loginWithRole(ROLE.endpoint_response_actions_access); + login(ROLE.endpoint_response_actions_access); }); after(() => { cleanupRule(ruleId); @@ -146,7 +146,7 @@ describe('Form', { tags: '@ess' }, () => { const [ruleName, ruleDescription] = generateRandomStringName(2); before(() => { - loginWithRole(ROLE.endpoint_response_actions_no_access); + login(ROLE.endpoint_response_actions_no_access); }); it('response actions are disabled', () => { @@ -166,7 +166,7 @@ describe('Form', { tags: '@ess' }, () => { loadRule().then((res) => { ruleId = res.id; }); - loginWithRole(ROLE.endpoint_response_actions_no_access); + login(ROLE.endpoint_response_actions_no_access); }); after(() => { cleanupRule(ruleId); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/no_license.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/no_license.cy.ts index edbaa90d3200c..eb6191ece0460 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/no_license.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/automated_response_actions/no_license.cy.ts @@ -9,7 +9,7 @@ import { disableExpandableFlyoutAdvancedSettings } from '../../tasks/common'; import { APP_ALERTS_PATH } from '../../../../../common/constants'; import { closeAllToasts } from '../../tasks/toasts'; import { fillUpNewRule } from '../../tasks/response_actions'; -import { login, loginWithRole, ROLE } from '../../tasks/login'; +import { login, ROLE } from '../../tasks/login'; import { generateRandomStringName } from '../../tasks/utils'; import type { ReturnTypeFromChainable } from '../../types'; import { indexEndpointHosts } from '../../tasks/index_endpoint_hosts'; @@ -20,7 +20,7 @@ describe('No License', { tags: '@ess', env: { ftrConfig: { license: 'basic' } } const [ruleName, ruleDescription] = generateRandomStringName(2); before(() => { - loginWithRole(ROLE.endpoint_response_actions_access); + login(ROLE.endpoint_response_actions_access); }); it('response actions are disabled', () => { diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/endpoint_list_with_security_essentials.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/endpoint_list_with_security_essentials.cy.ts index 58c41539361c5..32800978a968e 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/endpoint_list_with_security_essentials.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/endpoint_list_with_security_essentials.cy.ts @@ -7,7 +7,7 @@ import type { CyIndexEndpointHosts } from '../../tasks/index_endpoint_hosts'; import { indexEndpointHosts } from '../../tasks/index_endpoint_hosts'; -import { loginServerless } from '../../tasks/login_serverless'; +import { login } from '../../tasks/login'; import { getConsoleActionMenuItem, getUnIsolateActionMenuItem, @@ -42,7 +42,7 @@ describe( }); beforeEach(() => { - loginServerless(); + login(); visitEndpointList(); openRowActionMenu(); }); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete.cy.ts index 7c4323b2aa689..dba7166b9a9e8 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete.cy.ts @@ -6,7 +6,7 @@ */ import { ensureResponseActionAuthzAccess } from '../../../tasks/response_actions'; -import { loginServerless, ServerlessUser } from '../../../tasks/login_serverless'; +import { login, ROLE } from '../../../tasks/login'; import { RESPONSE_ACTION_API_COMMANDS_NAMES } from '../../../../../../common/endpoint/service/response_actions/constants'; import { getNoPrivilegesPage } from '../../../screens/common'; import { getEndpointManagementPageList } from '../../../screens'; @@ -31,7 +31,7 @@ describe( let password: string; beforeEach(() => { - loginServerless(ServerlessUser.ENDPOINT_OPERATIONS_ANALYST).then((response) => { + login(ROLE.endpoint_operations_analyst).then((response) => { username = response.username; password = response.password; }); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete_with_endpoint.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete_with_endpoint.cy.ts index f3ae8b85a9f24..3c028b9e25040 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete_with_endpoint.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete_with_endpoint.cy.ts @@ -6,7 +6,7 @@ */ import { ensureResponseActionAuthzAccess } from '../../../tasks/response_actions'; -import { loginServerless, ServerlessUser } from '../../../tasks/login_serverless'; +import { login, ROLE } from '../../../tasks/login'; import { RESPONSE_ACTION_API_COMMANDS_NAMES } from '../../../../../../common/endpoint/service/response_actions/constants'; import { getEndpointManagementPageList, @@ -33,7 +33,7 @@ describe( let password: string; beforeEach(() => { - loginServerless(ServerlessUser.ENDPOINT_OPERATIONS_ANALYST).then((response) => { + login(ROLE.endpoint_operations_analyst).then((response) => { username = response.username; password = response.password; }); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials.cy.ts index 900a5d81a9f46..fed4494722df5 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials.cy.ts @@ -6,7 +6,7 @@ */ import { ensureResponseActionAuthzAccess } from '../../../tasks/response_actions'; -import { loginServerless, ServerlessUser } from '../../../tasks/login_serverless'; +import { login, ROLE } from '../../../tasks/login'; import { RESPONSE_ACTION_API_COMMANDS_NAMES } from '../../../../../../common/endpoint/service/response_actions/constants'; import { getNoPrivilegesPage } from '../../../screens/common'; import { getEndpointManagementPageList } from '../../../screens'; @@ -33,7 +33,7 @@ describe( let password: string; beforeEach(() => { - loginServerless(ServerlessUser.ENDPOINT_OPERATIONS_ANALYST).then((response) => { + login(ROLE.endpoint_operations_analyst).then((response) => { username = response.username; password = response.password; }); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials_with_endpoint.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials_with_endpoint.cy.ts index 7196b73f6813a..172f850e44b7c 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials_with_endpoint.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials_with_endpoint.cy.ts @@ -6,7 +6,7 @@ */ import { ensureResponseActionAuthzAccess } from '../../../tasks/response_actions'; -import { loginServerless, ServerlessUser } from '../../../tasks/login_serverless'; +import { login, ROLE } from '../../../tasks/login'; import { RESPONSE_ACTION_API_COMMANDS_NAMES } from '../../../../../../common/endpoint/service/response_actions/constants'; import { getEndpointManagementPageMap, @@ -41,7 +41,7 @@ describe( let password: string; beforeEach(() => { - loginServerless(ServerlessUser.ENDPOINT_OPERATIONS_ANALYST).then((response) => { + login(ROLE.endpoint_operations_analyst).then((response) => { username = response.username; password = response.password; }); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/policy_details_with_security_essentials.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/policy_details_with_security_essentials.cy.ts index faf9aba6237b3..e1516bb08e10e 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/policy_details_with_security_essentials.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/policy_details_with_security_essentials.cy.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { loginServerless } from '../../tasks/login_serverless'; +import { login } from '../../tasks/login'; import { visitPolicyDetailsPage } from '../../screens/policy_details'; import type { IndexedFleetEndpointPolicyResponse } from '../../../../../common/endpoint/data_loaders/index_fleet_endpoint_policy'; @@ -35,7 +35,7 @@ describe( }); beforeEach(() => { - loginServerless(); + login(); visitPolicyDetailsPage(loadedPolicyData.integrationPolicies[0].id); }); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts index 879948a65c47d..534b681c2aeb4 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts @@ -8,7 +8,7 @@ import { pick } from 'lodash'; import type { CyIndexEndpointHosts } from '../../../tasks/index_endpoint_hosts'; import { indexEndpointHosts } from '../../../tasks/index_endpoint_hosts'; -import { loginServerless, ServerlessUser } from '../../../tasks/login_serverless'; +import { login, ROLE } from '../../../tasks/login'; import { ensurePolicyDetailsPageAuthzAccess } from '../../../screens/policy_details'; import type { EndpointArtifactPageId } from '../../../screens'; import { @@ -63,12 +63,12 @@ describe( }); // roles `t1_analyst` and `t2_analyst` are very similar with exception of one page - (['t1_analyst', `t2_analyst`] as ServerlessUser[]).forEach((roleName) => { + (['t1_analyst', `t2_analyst`] as ROLE[]).forEach((roleName) => { describe(`for role: ${roleName}`, () => { const deniedPages = allPages.filter((page) => page.id !== 'endpointList'); beforeEach(() => { - loginServerless(roleName); + login(roleName); }); it('should have READ access to Endpoint list page', () => { @@ -124,7 +124,7 @@ describe( const deniedResponseActions = pick(consoleHelpPanelResponseActionsTestSubj, 'execute'); beforeEach(() => { - loginServerless(ServerlessUser.T3_ANALYST); + login(ROLE.t3_analyst); }); it('should have access to Endpoint list page', () => { @@ -176,7 +176,7 @@ describe( const deniedPages = allPages.filter(({ id }) => id !== 'blocklist' && id !== 'endpointList'); beforeEach(() => { - loginServerless(ServerlessUser.THREAT_INTELLIGENCE_ANALYST); + login(ROLE.threat_intelligence_analyst); }); it('should have access to Endpoint list page', () => { @@ -221,7 +221,7 @@ describe( ]; beforeEach(() => { - loginServerless(ServerlessUser.RULE_AUTHOR); + login(ROLE.rule_author); }); for (const { id, title } of artifactPagesFullAccess) { @@ -272,7 +272,7 @@ describe( const grantedAccessPages = [pageById.endpointList, pageById.policyList]; beforeEach(() => { - loginServerless(ServerlessUser.SOC_MANAGER); + login(ROLE.soc_manager); }); for (const { id, title } of artifactPagesFullAccess) { @@ -319,7 +319,7 @@ describe( const grantedAccessPages = [pageById.endpointList, pageById.policyList]; beforeEach(() => { - loginServerless(ServerlessUser.ENDPOINT_OPERATIONS_ANALYST); + login(ROLE.endpoint_operations_analyst); }); for (const { id, title } of artifactPagesFullAccess) { @@ -350,7 +350,7 @@ describe( }); }); - (['platform_engineer', 'endpoint_policy_manager'] as ServerlessUser[]).forEach((roleName) => { + (['platform_engineer', 'endpoint_policy_manager'] as ROLE[]).forEach((roleName) => { describe(`for role: ${roleName}`, () => { const artifactPagesFullAccess = [ pageById.trustedApps, @@ -361,7 +361,7 @@ describe( const grantedAccessPages = [pageById.endpointList, pageById.policyList]; beforeEach(() => { - loginServerless(roleName); + login(roleName); }); for (const { id, title } of artifactPagesFullAccess) { diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/essentials_with_endpoint.roles.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/essentials_with_endpoint.roles.cy.ts index fec6a0f803afb..6cf3ab727980a 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/essentials_with_endpoint.roles.cy.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/roles/essentials_with_endpoint.roles.cy.ts @@ -7,7 +7,7 @@ import type { CyIndexEndpointHosts } from '../../../tasks/index_endpoint_hosts'; import { indexEndpointHosts } from '../../../tasks/index_endpoint_hosts'; -import { loginServerless, ServerlessUser } from '../../../tasks/login_serverless'; +import { login, ROLE } from '../../../tasks/login'; import type { EndpointArtifactPageId } from '../../../screens'; import { getNoPrivilegesPage, @@ -55,12 +55,12 @@ describe( }); // roles `t1_analyst` and `t2_analyst` are the same as far as endpoint access - (['t1_analyst', `t2_analyst`] as ServerlessUser[]).forEach((roleName) => { + (['t1_analyst', `t2_analyst`] as ROLE[]).forEach((roleName) => { describe(`for role: ${roleName}`, () => { const deniedPages = allPages.filter((page) => page.id !== 'endpointList'); beforeEach(() => { - loginServerless(roleName); + login(roleName); }); it('should have READ access to Endpoint list page', () => { @@ -89,7 +89,7 @@ describe( ]; beforeEach(() => { - loginServerless(ServerlessUser.T3_ANALYST); + login(ROLE.t3_analyst); }); it('should have access to Endpoint list page', () => { @@ -128,7 +128,7 @@ describe( const deniedPages = allPages.filter(({ id }) => id !== 'blocklist' && id !== 'endpointList'); beforeEach(() => { - loginServerless(ServerlessUser.THREAT_INTELLIGENCE_ANALYST); + login(ROLE.threat_intelligence_analyst); }); it('should have access to Endpoint list page', () => { @@ -163,7 +163,7 @@ describe( ]; beforeEach(() => { - loginServerless(ServerlessUser.RULE_AUTHOR); + login(ROLE.rule_author); }); for (const { id, title } of artifactPagesFullAccess) { @@ -207,7 +207,7 @@ describe( const grantedAccessPages = [pageById.endpointList, pageById.policyList]; beforeEach(() => { - loginServerless(ServerlessUser.SOC_MANAGER); + login(ROLE.soc_manager); }); for (const { id, title } of artifactPagesFullAccess) { @@ -238,11 +238,7 @@ describe( // Endpoint Operations Manager, Endpoint Policy Manager and Platform Engineer currently have the same level of access ( - [ - 'platform_engineer', - `endpoint_operations_analyst`, - 'endpoint_policy_manager', - ] as ServerlessUser[] + ['platform_engineer', `endpoint_operations_analyst`, 'endpoint_policy_manager'] as ROLE[] ).forEach((roleName) => { describe(`for role: ${roleName}`, () => { const artifactPagesFullAccess = [ @@ -253,7 +249,7 @@ describe( const grantedAccessPages = [pageById.endpointList, pageById.policyList]; beforeEach(() => { - loginServerless(roleName); + login(roleName); }); for (const { id, title } of artifactPagesFullAccess) { diff --git a/x-pack/plugins/security_solution/public/management/cypress/fixtures/role_with_artifact_read_privilege.ts b/x-pack/plugins/security_solution/public/management/cypress/fixtures/role_with_artifact_read_privilege.ts new file mode 100644 index 0000000000000..247b491f04632 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/fixtures/role_with_artifact_read_privilege.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getEndpointSecurityPolicyManager } from '../../../../scripts/endpoint/common/roles_users'; + +export const getRoleWithArtifactReadPrivilege = (privilegePrefix: string) => { + const endpointSecurityPolicyManagerRole = getEndpointSecurityPolicyManager(); + + return { + ...endpointSecurityPolicyManagerRole, + kibana: [ + { + ...endpointSecurityPolicyManagerRole.kibana[0], + feature: { + ...endpointSecurityPolicyManagerRole.kibana[0].feature, + siem: [ + ...endpointSecurityPolicyManagerRole.kibana[0].feature.siem.filter( + (privilege) => privilege !== `${privilegePrefix}all` + ), + `${privilegePrefix}read`, + ], + }, + }, + ], + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts b/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts index 4d8164391cb11..93614b6dbb86d 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts @@ -9,6 +9,14 @@ import type { CasePostRequest } from '@kbn/cases-plugin/common'; import execa from 'execa'; +import type { KbnClient } from '@kbn/test'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type { KibanaKnownUserAccounts } from '../common/constants'; +import { KIBANA_KNOWN_DEFAULT_ACCOUNTS } from '../common/constants'; +import type { EndpointSecurityRoleNames } from '../../../../scripts/endpoint/common/roles_users'; +import { SECURITY_SERVERLESS_ROLE_NAMES } from '../../../../scripts/endpoint/common/roles_users'; +import type { LoadedRoleAndUser } from '../../../../scripts/endpoint/common/role_and_user_loader'; +import { EndpointSecurityTestRolesLoader } from '../../../../scripts/endpoint/common/role_and_user_loader'; import { startRuntimeServices } from '../../../../scripts/endpoint/endpoint_agent_runner/runtime'; import { runFleetServerIfNeeded } from '../../../../scripts/endpoint/endpoint_agent_runner/fleet_server'; import { @@ -22,39 +30,89 @@ import type { CreateAndEnrollEndpointHostOptions, CreateAndEnrollEndpointHostResponse, } from '../../../../scripts/endpoint/common/endpoint_host_services'; +import { + createAndEnrollEndpointHost, + destroyEndpointHost, + startEndpointHost, + stopEndpointHost, + VAGRANT_CWD, +} from '../../../../scripts/endpoint/common/endpoint_host_services'; import type { IndexedEndpointPolicyResponse } from '../../../../common/endpoint/data_loaders/index_endpoint_policy_response'; import { deleteIndexedEndpointPolicyResponse, indexEndpointPolicyResponse, } from '../../../../common/endpoint/data_loaders/index_endpoint_policy_response'; import type { ActionDetails, HostPolicyResponse } from '../../../../common/endpoint/types'; -import type { IndexEndpointHostsCyTaskOptions } from '../types'; import type { - IndexedEndpointRuleAlerts, + IndexEndpointHostsCyTaskOptions, + LoadUserAndRoleCyTaskOptions, + CreateUserAndRoleCyTaskOptions, +} from '../types'; +import type { DeletedIndexedEndpointRuleAlerts, + IndexedEndpointRuleAlerts, +} from '../../../../common/endpoint/data_loaders/index_endpoint_rule_alerts'; +import { + deleteIndexedEndpointRuleAlerts, + indexEndpointRuleAlerts, } from '../../../../common/endpoint/data_loaders/index_endpoint_rule_alerts'; import type { IndexedHostsAndAlertsResponse } from '../../../../common/endpoint/index_data'; +import { deleteIndexedHostsAndAlerts } from '../../../../common/endpoint/index_data'; import type { IndexedCase } from '../../../../common/endpoint/data_loaders/index_case'; +import { deleteIndexedCase, indexCase } from '../../../../common/endpoint/data_loaders/index_case'; import { createRuntimeServices } from '../../../../scripts/endpoint/common/stack_services'; import type { IndexedFleetEndpointPolicyResponse } from '../../../../common/endpoint/data_loaders/index_fleet_endpoint_policy'; import { - indexFleetEndpointPolicy, deleteIndexedFleetEndpointPolicies, + indexFleetEndpointPolicy, } from '../../../../common/endpoint/data_loaders/index_fleet_endpoint_policy'; -import { deleteIndexedCase, indexCase } from '../../../../common/endpoint/data_loaders/index_case'; import { cyLoadEndpointDataHandler } from './plugin_handlers/endpoint_data_loader'; -import { deleteIndexedHostsAndAlerts } from '../../../../common/endpoint/index_data'; -import { - deleteIndexedEndpointRuleAlerts, - indexEndpointRuleAlerts, -} from '../../../../common/endpoint/data_loaders/index_endpoint_rule_alerts'; -import { - startEndpointHost, - createAndEnrollEndpointHost, - destroyEndpointHost, - stopEndpointHost, - VAGRANT_CWD, -} from '../../../../scripts/endpoint/common/endpoint_host_services'; + +/** + * Test Role/User loader for cypress. Checks to see if running in serverless and handles it as appropriate + */ +class TestRoleAndUserLoader extends EndpointSecurityTestRolesLoader { + constructor( + protected readonly kbnClient: KbnClient, + protected readonly logger: ToolingLog, + private readonly isServerless: boolean + ) { + super(kbnClient, logger); + } + + async load( + name: EndpointSecurityRoleNames | KibanaKnownUserAccounts + ): Promise { + // If its a known system account, then just exit here and use the default `changeme` password + if (KIBANA_KNOWN_DEFAULT_ACCOUNTS[name as KibanaKnownUserAccounts]) { + return { + role: name, + username: name, + password: 'changeme', + }; + } + + if (this.isServerless) { + // If the username is not one that we support in serverless, then throw an error. + if (!SECURITY_SERVERLESS_ROLE_NAMES[name as keyof typeof SECURITY_SERVERLESS_ROLE_NAMES]) { + throw new Error( + `username [${name}] is not valid when running in serverless. Valid values are: ${Object.keys( + SECURITY_SERVERLESS_ROLE_NAMES + ).join(', ')}` + ); + } + + // Roles/users for serverless will be already present in the env, so just return the defaults creds + return { + role: name, + username: name, + password: 'changeme', + }; + } + + return super.load(name as EndpointSecurityRoleNames); + } +} /** * Cypress plugin for adding data loading related `task`s @@ -68,7 +126,8 @@ export const dataLoaders = ( on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions ): void => { - // FIXME: investigate if we can create a `ToolingLog` that writes output to cypress and pass that to the stack services + // Env. variable is set by `cypress_serverless.config.ts` + const isServerless = config.env.IS_SERVERLESS; const stackServicesPromise = createRuntimeServices({ kibanaUrl: config.env.KIBANA_URL, @@ -81,6 +140,12 @@ export const dataLoaders = ( asSuperuser: true, }); + const roleAndUserLoaderPromise: Promise = stackServicesPromise.then( + ({ kbnClient, log }) => { + return new TestRoleAndUserLoader(kbnClient, log, isServerless); + } + ); + on('task', { indexFleetEndpointPolicy: async ({ policyName, @@ -201,6 +266,23 @@ export const dataLoaders = ( const { esClient } = await stackServicesPromise; return deleteAllEndpointData(esClient, endpointAgentIds); }, + + /** + * Loads a user/role into Kibana. Used from `login()` task. + * @param name + */ + loadUserAndRole: async ({ name }: LoadUserAndRoleCyTaskOptions): Promise => { + return (await roleAndUserLoaderPromise).load(name); + }, + + /** + * Creates a new Role/User + */ + createUserAndRole: async ({ + role, + }: CreateUserAndRoleCyTaskOptions): Promise => { + return (await roleAndUserLoaderPromise).create(role); + }, }); }; diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/login.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/login.ts index 58eba62a72f82..8ac78b508d084 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/login.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/login.ts @@ -5,242 +5,107 @@ * 2.0. */ -import * as yaml from 'js-yaml'; -import type { Role } from '@kbn/security-plugin/common'; import type { LoginState } from '@kbn/security-plugin/common/login_state'; -import { getWithResponseActionsRole } from '../../../../scripts/endpoint/common/roles_users/with_response_actions_role'; -import { getNoResponseActionsRole } from '../../../../scripts/endpoint/common/roles_users/without_response_actions_role'; -import { request } from './common'; -import { getT1Analyst } from '../../../../scripts/endpoint/common/roles_users/t1_analyst'; -import { getT2Analyst } from '../../../../scripts/endpoint/common/roles_users/t2_analyst'; -import { getHunter } from '../../../../scripts/endpoint/common/roles_users/hunter'; -import { getThreatIntelligenceAnalyst } from '../../../../scripts/endpoint/common/roles_users/threat_intelligence_analyst'; -import { getSocManager } from '../../../../scripts/endpoint/common/roles_users/soc_manager'; -import { getPlatformEngineer } from '../../../../scripts/endpoint/common/roles_users/platform_engineer'; -import { getEndpointOperationsAnalyst } from '../../../../scripts/endpoint/common/roles_users/endpoint_operations_analyst'; -import { - getEndpointSecurityPolicyManagementReadRole, - getEndpointSecurityPolicyManager, -} from '../../../../scripts/endpoint/common/roles_users/endpoint_security_policy_manager'; -import { getDetectionsEngineer } from '../../../../scripts/endpoint/common/roles_users/detections_engineer'; - -export enum ROLE { - t1_analyst = 't1Analyst', - t2_analyst = 't2Analyst', - analyst_hunter = 'hunter', - threat_intelligence_analyst = 'threatIntelligenceAnalyst', - detections_engineer = 'detectionsEngineer', - soc_manager = 'socManager', - platform_engineer = 'platformEngineer', - endpoint_operations_analyst = 'endpointOperationsAnalyst', - endpoint_security_policy_manager = 'endpointSecurityPolicyManager', - endpoint_response_actions_access = 'endpointResponseActionsAccess', - endpoint_response_actions_no_access = 'endpointResponseActionsNoAccess', - endpoint_security_policy_management_read = 'endpointSecurityPolicyManagementRead', +import type { Role } from '@kbn/security-plugin/common'; +import { ENDPOINT_SECURITY_ROLE_NAMES } from '../../../../scripts/endpoint/common/roles_users'; +import type { SecurityTestUser } from '../common/constants'; +import { COMMON_API_HEADERS, request } from './common'; + +export const ROLE = Object.freeze>({ + ...ENDPOINT_SECURITY_ROLE_NAMES, + elastic: 'elastic', + elastic_serverless: 'elastic_serverless', + system_indices_superuser: 'system_indices_superuser', +}); + +interface CyLoginTask { + (user?: SecurityTestUser): ReturnType; + + /** + * Login using any username/password + * @param username + * @param password + */ + with(username: string, password: string): ReturnType; + + /** + * Creates the provided role in kibana/ES along with a respective user (same name as role) + * and then login with this new user + * @param role + */ + withCustomRole(role: Role): ReturnType; } -export const rolesMapping: { [key in ROLE]: Omit } = { - t1Analyst: getT1Analyst(), - t2Analyst: getT2Analyst(), - hunter: getHunter(), - threatIntelligenceAnalyst: getThreatIntelligenceAnalyst(), - socManager: getSocManager(), - platformEngineer: getPlatformEngineer(), - endpointOperationsAnalyst: getEndpointOperationsAnalyst(), - endpointSecurityPolicyManager: getEndpointSecurityPolicyManager(), - detectionsEngineer: getDetectionsEngineer(), - endpointResponseActionsAccess: getWithResponseActionsRole(), - endpointResponseActionsNoAccess: getNoResponseActionsRole(), - endpointSecurityPolicyManagementRead: getEndpointSecurityPolicyManagementReadRole(), -}; -/** - * Credentials in the `kibana.dev.yml` config file will be used to authenticate - * with Kibana when credentials are not provided via environment variables - */ -const KIBANA_DEV_YML_PATH = '../../../config/kibana.dev.yml'; - -/** - * The configuration path in `kibana.dev.yml` to the username to be used when - * authenticating with Kibana. - */ -const ELASTICSEARCH_USERNAME_CONFIG_PATH = 'config.elasticsearch.username'; - -/** - * The configuration path in `kibana.dev.yml` to the password to be used when - * authenticating with Kibana. - */ -const ELASTICSEARCH_PASSWORD_CONFIG_PATH = 'config.elasticsearch.password'; - -/** - * The `CYPRESS_ELASTICSEARCH_USERNAME` environment variable specifies the - * username to be used when authenticating with Kibana - */ -const ELASTICSEARCH_USERNAME = 'ELASTICSEARCH_USERNAME'; - /** - * The `CYPRESS_ELASTICSEARCH_PASSWORD` environment variable specifies the - * username to be used when authenticating with Kibana + * Login to Kibana using API (not login page). + * By default, user will be logged in using `KIBANA_USERNAME` and `KIBANA_PASSWORD` retrieved from + * the cypress `env` + * + * @param user */ -const ELASTICSEARCH_PASSWORD = 'ELASTICSEARCH_PASSWORD'; - -const KIBANA_USERNAME = 'KIBANA_USERNAME'; -const KIBANA_PASSWORD = 'KIBANA_PASSWORD'; +export const login: CyLoginTask = ( + // FIXME:PT default user to `soc_manager` + user?: SecurityTestUser +): ReturnType => { + let username = Cypress.env('KIBANA_USERNAME'); + let password = Cypress.env('KIBANA_PASSWORD'); + + if (user) { + return cy.task('loadUserAndRole', { name: user }).then((loadedUser) => { + username = loadedUser.username; + password = loadedUser.password; + + return sendApiLoginRequest(username, password); + }); + } else { + return sendApiLoginRequest(username, password); + } +}; -export const createCustomRoleAndUser = (role: string, rolePrivileges: Omit) => { - // post the role - request({ - method: 'PUT', - url: `/api/security/role/${role}`, - body: rolePrivileges, - }); +login.with = (username: string, password: string): ReturnType => { + return sendApiLoginRequest(username, password); +}; - // post the user associated with the role to elasticsearch - request({ - method: 'POST', - url: `/internal/security/users/${role}`, - body: { - username: role, - password: Cypress.env(ELASTICSEARCH_PASSWORD), - roles: [role], - }, +login.withCustomRole = (role: Role): ReturnType => { + return cy.task('createUserAndRole', { role }).then(({ username, password }) => { + return sendApiLoginRequest(username, password); }); }; -const loginWithUsernameAndPassword = (username: string, password: string) => { +/** + * Send login via API + * @param username + * @param password + * + * @private + */ +const sendApiLoginRequest = ( + username: string, + password: string +): Cypress.Chainable<{ username: string; password: string }> => { const baseUrl = Cypress.config().baseUrl; - if (!baseUrl) { - throw Error(`Cypress config baseUrl not set!`); - } + const loginUrl = `${baseUrl}/internal/security/login`; + const headers = { ...COMMON_API_HEADERS }; + + cy.log(`Authenticating [${username}] via ${loginUrl}`); - // Programmatically authenticate without interacting with the Kibana login page. - const headers = { 'kbn-xsrf': 'cypress-creds' }; - request({ headers, url: `${baseUrl}/internal/security/login_state` }).then( - (loginState) => { + return request({ headers, url: `${baseUrl}/internal/security/login_state` }) + .then((loginState) => { const basicProvider = loginState.body.selector.providers.find( (provider) => provider.type === 'basic' ); + return request({ - url: `${baseUrl}/internal/security/login`, + url: loginUrl, method: 'POST', headers, body: { - providerType: basicProvider.type, - providerName: basicProvider.name, + providerType: basicProvider?.type, + providerName: basicProvider?.name, currentURL: '/', params: { username, password }, }, }); - } - ); -}; - -export const loginWithRole = (role: ROLE) => { - loginWithCustomRole(role, rolesMapping[role]); -}; - -export const loginWithCustomRole = (role: string, rolePrivileges: Omit) => { - createCustomRoleAndUser(role, rolePrivileges); - - cy.log(`origin: ${Cypress.config().baseUrl}`); - - loginWithUsernameAndPassword(role, Cypress.env(ELASTICSEARCH_PASSWORD)); -}; - -/** - * Authenticates with Kibana using, if specified, credentials specified by - * environment variables. The credentials in `kibana.dev.yml` will be used - * for authentication when the environment variables are unset. - * - * To speed the execution of tests, prefer this non-interactive authentication, - * which is faster than authentication via Kibana's interactive login page. - */ -export const login = (role?: ROLE) => { - if (role != null) { - loginWithRole(role); - } else if (credentialsProvidedByEnvironment()) { - loginViaEnvironmentCredentials(); - } else { - loginViaConfig(); - } -}; - -/** - * Returns `true` if the credentials used to login to Kibana are provided - * via environment variables - */ -const credentialsProvidedByEnvironment = (): boolean => - (Cypress.env(KIBANA_USERNAME) != null && Cypress.env(KIBANA_PASSWORD) != null) || - (Cypress.env(ELASTICSEARCH_USERNAME) != null && Cypress.env(ELASTICSEARCH_PASSWORD) != null); - -/** - * Authenticates with Kibana by reading credentials from the - * `CYPRESS_ELASTICSEARCH_USERNAME` and `CYPRESS_ELASTICSEARCH_PASSWORD` - * environment variables, and POSTing the username and password directly to - * Kibana's `/internal/security/login` endpoint, bypassing the login page (for speed). - */ -const loginViaEnvironmentCredentials = () => { - let username: string; - let password: string; - let usernameEnvVar: string; - let passwordEnvVar: string; - - if (Cypress.env(KIBANA_USERNAME) && Cypress.env(KIBANA_PASSWORD)) { - username = Cypress.env(KIBANA_USERNAME); - password = Cypress.env(KIBANA_PASSWORD); - usernameEnvVar = KIBANA_USERNAME; - passwordEnvVar = KIBANA_PASSWORD; - } else { - username = Cypress.env(ELASTICSEARCH_USERNAME); - password = Cypress.env(ELASTICSEARCH_PASSWORD); - usernameEnvVar = ELASTICSEARCH_USERNAME; - passwordEnvVar = ELASTICSEARCH_PASSWORD; - } - - cy.log( - `Authenticating user [${username}] retrieved via environment credentials from the \`CYPRESS_${usernameEnvVar}\` and \`CYPRESS_${passwordEnvVar}\` environment variables` - ); - - loginWithUsernameAndPassword(username, password); -}; - -/** - * Authenticates with Kibana by reading credentials from the - * `kibana.dev.yml` file and POSTing the username and password directly to - * Kibana's `/internal/security/login` endpoint, bypassing the login page (for speed). - */ -const loginViaConfig = () => { - cy.log( - `Authenticating via config credentials \`${ELASTICSEARCH_USERNAME_CONFIG_PATH}\` and \`${ELASTICSEARCH_PASSWORD_CONFIG_PATH}\` from \`${KIBANA_DEV_YML_PATH}\`` - ); - - // read the login details from `kibana.dev.yaml` - cy.readFile(KIBANA_DEV_YML_PATH).then((kibanaDevYml) => { - const config = yaml.safeLoad(kibanaDevYml); - loginWithUsernameAndPassword( - Cypress.env(ELASTICSEARCH_USERNAME), - config.elasticsearch.password - ); - }); -}; - -export const getRoleWithArtifactReadPrivilege = (privilegePrefix: string) => { - const endpointSecurityPolicyManagerRole = getEndpointSecurityPolicyManager(); - - return { - ...endpointSecurityPolicyManagerRole, - kibana: [ - { - ...endpointSecurityPolicyManagerRole.kibana[0], - feature: { - ...endpointSecurityPolicyManagerRole.kibana[0].feature, - siem: [ - ...endpointSecurityPolicyManagerRole.kibana[0].feature.siem.filter( - (privilege) => privilege !== `${privilegePrefix}all` - ), - `${privilegePrefix}read`, - ], - }, - }, - ], - }; + }) + .then(() => ({ username, password })); }; diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/login_serverless.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/login_serverless.ts deleted file mode 100644 index 533a17663e16b..0000000000000 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/login_serverless.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { LoginState } from '@kbn/security-plugin/common/login_state'; -import { COMMON_API_HEADERS, request } from './common'; - -export enum ServerlessUser { - T1_ANALYST = 't1_analyst', - T2_ANALYST = 't2_analyst', - T3_ANALYST = 't3_analyst', - THREAT_INTELLIGENCE_ANALYST = 'threat_intelligence_analyst', - RULE_AUTHOR = 'rule_author', - SOC_MANAGER = 'soc_manager', - DETECTIONS_ADMIN = 'detections_admin', - PLATFORM_ENGINEER = 'platform_engineer', - ENDPOINT_OPERATIONS_ANALYST = 'endpoint_operations_analyst', - ENDPOINT_POLICY_MANAGER = 'endpoint_policy_manager', -} - -/** - * Send login via API - * @param username - * @param password - * - * @private - */ -const sendApiLoginRequest = ( - username: string, - password: string -): Cypress.Chainable<{ username: string; password: string }> => { - const baseUrl = Cypress.config().baseUrl; - const headers = { ...COMMON_API_HEADERS }; - - cy.log(`Authenticating [${username}] via ${baseUrl}`); - - return request({ headers, url: `${baseUrl}/internal/security/login_state` }) - .then((loginState) => { - const basicProvider = loginState.body.selector.providers.find( - (provider) => provider.type === 'basic' - ); - - return request({ - url: `${baseUrl}/internal/security/login`, - method: 'POST', - headers, - body: { - providerType: basicProvider?.type, - providerName: basicProvider?.name, - currentURL: '/', - params: { username, password }, - }, - }); - }) - .then(() => ({ username, password })); -}; - -interface CyLoginTask { - (user?: ServerlessUser | 'elastic'): ReturnType; - - /** - * Login using any username/password - * @param username - * @param password - */ - with(username: string, password: string): ReturnType; -} - -/** - * Login to Kibana using API (not login page). By default, user will be logged in using - * the username and password defined via `KIBANA_USERNAME` and `KIBANA_PASSWORD` cypress env - * variables. - * @param user Defaults to `soc_manager` - */ -export const loginServerless: CyLoginTask = ( - user: ServerlessUser | 'elastic' = ServerlessUser.SOC_MANAGER -): ReturnType => { - const username = Cypress.env('KIBANA_USERNAME'); - const password = Cypress.env('KIBANA_PASSWORD'); - - if (user && user !== 'elastic') { - throw new Error('Serverless usernames not yet implemented'); - - // return cy.task('loadUserAndRole', { name: user }).then((loadedUser) => { - // username = loadedUser.username; - // password = loadedUser.password; - // - // return sendApiLoginRequest(username, password); - // }); - } else { - return sendApiLoginRequest(username, password); - } -}; - -loginServerless.with = ( - username: string, - password: string -): ReturnType => { - return sendApiLoginRequest(username, password); -}; diff --git a/x-pack/plugins/security_solution/public/management/cypress/tsconfig.json b/x-pack/plugins/security_solution/public/management/cypress/tsconfig.json index 4d7fe47ec2d23..94d50ffe7f62a 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tsconfig.json +++ b/x-pack/plugins/security_solution/public/management/cypress/tsconfig.json @@ -32,5 +32,6 @@ "@kbn/test", "@kbn/repo-info", "@kbn/data-views-plugin", + "@kbn/tooling-log", ] } diff --git a/x-pack/plugins/security_solution/public/management/cypress/types.ts b/x-pack/plugins/security_solution/public/management/cypress/types.ts index fecaa33a6a70a..aee97723c7d51 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/types.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/types.ts @@ -7,8 +7,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Role } from '@kbn/security-plugin/common'; import type { ActionDetails } from '../../../common/endpoint/types'; import type { CyLoadEndpointDataOptions } from './support/plugin_handlers/endpoint_data_loader'; +import type { SecurityTestUser } from './common/constants'; type PossibleChainable = | Cypress.Chainable @@ -56,3 +58,11 @@ export interface HostActionResponse { state: { state?: 'success' | 'failure' }; }; } + +export interface LoadUserAndRoleCyTaskOptions { + name: SecurityTestUser; +} + +export interface CreateUserAndRoleCyTaskOptions { + role: Role; +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/constants.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/constants.ts index 1d2b3d5f47784..4a8b2daca2af4 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/constants.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/constants.ts @@ -10,3 +10,8 @@ export const HORIZONTAL_LINE = '-'.repeat(80); export const ENDPOINT_EVENTS_INDEX = 'logs-endpoint.events.process-default'; export const ENDPOINT_ALERTS_INDEX = 'logs-endpoint.alerts-default'; + +export const COMMON_API_HEADERS = Object.freeze({ + 'kbn-xsrf': 'security-solution', + 'x-elastic-internal-origin': 'security-solution', +}); diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/role_and_user_loader.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/role_and_user_loader.ts new file mode 100644 index 0000000000000..f8c51d5255018 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/role_and_user_loader.ts @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable max-classes-per-file */ + +import type { KbnClient } from '@kbn/test'; +import type { Role } from '@kbn/security-plugin/common'; +import type { ToolingLog } from '@kbn/tooling-log'; +import { inspect } from 'util'; +import type { AxiosError } from 'axios'; +import type { EndpointSecurityRoleDefinitions } from './roles_users'; +import { getAllEndpointSecurityRoles } from './roles_users'; +import { catchAxiosErrorFormatAndThrow } from './format_axios_error'; +import { COMMON_API_HEADERS } from './constants'; + +const ignoreHttp409Error = (error: AxiosError) => { + if (error?.response?.status === 409) { + return; + } + + throw error; +}; + +export interface LoadedRoleAndUser { + role: string; + username: string; + password: string; +} + +export interface RoleAndUserLoaderInterface = Record> { + /** + * Loads the requested Role into kibana and then creates a user by the same role name that is + * assigned to the given role + * @param name + */ + load(name: keyof R): Promise; + + /** + * Loads all roles/users + */ + loadAll(): Promise>; + + /** + * Creates a new Role in kibana along with a user (by the same name as the Role name) + * that is assigned to the given role + * @param role + */ + create(role: Role): Promise; +} + +/** + * A generic class for loading roles and creating associated user into kibana + */ +export class RoleAndUserLoader = Record> + implements RoleAndUserLoaderInterface +{ + protected readonly logPromiseError: (error: Error) => never; + + constructor( + protected readonly kbnClient: KbnClient, + protected readonly logger: ToolingLog, + protected readonly roles: R + ) { + this.logPromiseError = (error) => { + this.logger.error(inspect(error, { depth: 5 })); + throw error; + }; + } + + async load(name: keyof R): Promise { + const role = this.roles[name]; + + if (!role) { + throw new Error( + `Unknown role/user: [${String(name)}]. Valid values are: [${Object.keys(this.roles).join( + ', ' + )}]` + ); + } + + return this.create(role); + } + + async loadAll(): Promise> { + const response = {} as Record; + + for (const [name, role] of Object.entries(this.roles)) { + response[name as keyof R] = await this.create(role); + } + + return response; + } + + public async create(role: Role): Promise { + const roleName = role.name; + + await this.createRole(role); + await this.createUser(roleName, 'changeme', [roleName]); + + return { + role: roleName, + username: roleName, + password: 'changeme', + }; + } + + protected async createRole(role: Role): Promise { + const { name: roleName, ...roleDefinition } = role; + + this.logger.debug(`creating role:`, roleDefinition); + + await this.kbnClient + .request({ + method: 'PUT', + path: `/api/security/role/${roleName}`, + headers: { + ...COMMON_API_HEADERS, + }, + body: roleDefinition, + }) + .then((response) => { + this.logger.debug(`Role [${roleName}] created/updated`, response?.data); + return response; + }) + .catch(ignoreHttp409Error) + .catch(catchAxiosErrorFormatAndThrow) + .catch(this.logPromiseError); + } + + protected async createUser( + username: string, + password: string, + roles: string[] = [] + ): Promise { + const user = { + username, + password, + roles, + full_name: username, + email: '', + }; + + this.logger.debug(`creating user:`, user); + + await this.kbnClient + .request({ + method: 'POST', + path: `/internal/security/users/${username}`, + headers: { + ...COMMON_API_HEADERS, + }, + body: user, + }) + .then((response) => { + this.logger.debug(`User [${username}] created/updated`, response?.data); + return response; + }) + .catch(ignoreHttp409Error) + .catch(catchAxiosErrorFormatAndThrow) + .catch(this.logPromiseError); + } +} + +/** + * Role and user loader for Endpoint security dev/testing + */ +export class EndpointSecurityTestRolesLoader extends RoleAndUserLoader { + constructor(protected readonly kbnClient: KbnClient, protected readonly logger: ToolingLog) { + super(kbnClient, logger, getAllEndpointSecurityRoles()); + } +} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/index.ts new file mode 100644 index 0000000000000..b035f55bf1589 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/index.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Role } from '@kbn/security-plugin/common'; +import { getRuleAuthor } from './rule_author'; +import { getT3Analyst } from './t3_analyst'; +import { getT1Analyst } from './t1_analyst'; +import { getT2Analyst } from './t2_analyst'; +import { getHunter } from './hunter'; +import { getThreatIntelligenceAnalyst } from './threat_intelligence_analyst'; +import { getSocManager } from './soc_manager'; +import { getPlatformEngineer } from './platform_engineer'; +import { getEndpointOperationsAnalyst } from './endpoint_operations_analyst'; +import { + getEndpointSecurityPolicyManagementReadRole, + getEndpointSecurityPolicyManager, +} from './endpoint_security_policy_manager'; +import { getDetectionsEngineer } from './detections_engineer'; +import { getWithResponseActionsRole } from './with_response_actions_role'; +import { getNoResponseActionsRole } from './without_response_actions_role'; + +export * from './with_response_actions_role'; +export * from './without_response_actions_role'; +export * from './t1_analyst'; +export * from './t2_analyst'; +export * from './t3_analyst'; +export * from './hunter'; +export * from './threat_intelligence_analyst'; +export * from './soc_manager'; +export * from './platform_engineer'; +export * from './endpoint_operations_analyst'; +export * from './endpoint_security_policy_manager'; +export * from './detections_engineer'; + +export type EndpointSecurityRoleNames = keyof typeof ENDPOINT_SECURITY_ROLE_NAMES; + +export type EndpointSecurityRoleDefinitions = Record; + +/** + * Security Solution set of roles that are loaded and used in serverless deployments. + * The source of these role definitions is under `project-controller` at: + * + * @see https://github.com/elastic/project-controller/blob/main/internal/project/security/config/roles.yml + * + * The role definition spreadsheet can be found here: + * + * @see https://docs.google.com/spreadsheets/d/16aGow187AunLCBFZLlbVyS81iQNuMpNxd96LOerWj4c/edit#gid=1936689222 + */ +export const SECURITY_SERVERLESS_ROLE_NAMES = Object.freeze({ + t1_analyst: 't1_analyst', + t2_analyst: 't2_analyst', + t3_analyst: 't3_analyst', + threat_intelligence_analyst: 'threat_intelligence_analyst', + rule_author: 'rule_author', + soc_manager: 'soc_manager', + detections_admin: 'detections_admin', + platform_engineer: 'platform_engineer', + endpoint_operations_analyst: 'endpoint_operations_analyst', + endpoint_policy_manager: 'endpoint_policy_manager', +}); + +export const ENDPOINT_SECURITY_ROLE_NAMES = Object.freeze({ + // -------------------------------------- + // Set of roles used in serverless + ...SECURITY_SERVERLESS_ROLE_NAMES, + + // -------------------------------------- + // Other roles used for testing + hunter: 'hunter', + endpoint_response_actions_access: 'endpoint_response_actions_access', + endpoint_response_actions_no_access: 'endpoint_response_actions_no_access', + endpoint_security_policy_management_read: 'endpoint_security_policy_management_read', +}); + +export const getAllEndpointSecurityRoles = (): EndpointSecurityRoleDefinitions => { + return { + t1_analyst: { + ...getT1Analyst(), + name: 't1_analyst', + }, + t2_analyst: { + ...getT2Analyst(), + name: 't2_analyst', + }, + t3_analyst: { + ...getT3Analyst(), + name: 't3_analyst', + }, + threat_intelligence_analyst: { + ...getThreatIntelligenceAnalyst(), + name: 'threat_intelligence_analyst', + }, + rule_author: { + ...getRuleAuthor(), + name: 'rule_author', + }, + soc_manager: { + ...getSocManager(), + name: 'soc_manager', + }, + detections_admin: { + ...getDetectionsEngineer(), + name: 'detections_admin', + }, + platform_engineer: { + ...getPlatformEngineer(), + name: 'platform_engineer', + }, + endpoint_operations_analyst: { + ...getEndpointOperationsAnalyst(), + name: 'endpoint_operations_analyst', + }, + endpoint_policy_manager: { + ...getEndpointSecurityPolicyManager(), + name: 'endpoint_policy_manager', + }, + + hunter: { + ...getHunter(), + name: 'hunter', + }, + endpoint_response_actions_access: { + ...getWithResponseActionsRole(), + name: 'endpoint_response_actions_access', + }, + endpoint_response_actions_no_access: { + ...getNoResponseActionsRole(), + name: 'endpoint_response_actions_no_access', + }, + endpoint_security_policy_management_read: { + ...getEndpointSecurityPolicyManagementReadRole(), + name: 'endpoint_security_policy_management_read', + }, + }; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/rule_author.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/rule_author.ts new file mode 100644 index 0000000000000..f957fe8947c5d --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/rule_author.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Role } from '@kbn/security-plugin/common'; +import { getNoResponseActionsRole } from './without_response_actions_role'; + +export const getRuleAuthor: () => Omit = () => { + const noResponseActionsRole = getNoResponseActionsRole(); + return { + ...noResponseActionsRole, + kibana: [ + { + ...noResponseActionsRole.kibana[0], + feature: { + ...noResponseActionsRole.kibana[0].feature, + siem: [ + 'all', + 'read_alerts', + 'crud_alerts', + 'policy_management_all', + 'endpoint_list_all', + 'trusted_applications_all', + 'event_filters_all', + 'host_isolation_exceptions_read', + 'blocklist_all', + 'actions_log_management_read', + ], + }, + }, + ], + }; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/t3_analyst.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/t3_analyst.ts new file mode 100644 index 0000000000000..304c4e6d744ee --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/t3_analyst.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Role } from '@kbn/security-plugin/common'; +import { getNoResponseActionsRole } from './without_response_actions_role'; + +export const getT3Analyst: () => Omit = () => { + const noResponseActionsRole = getNoResponseActionsRole(); + return { + ...noResponseActionsRole, + kibana: [ + { + ...noResponseActionsRole.kibana[0], + feature: { + ...noResponseActionsRole.kibana[0].feature, + siem: [ + 'all', + 'read_alerts', + 'crud_alerts', + 'endpoint_list_all', + 'trusted_applications_all', + 'event_filters_all', + 'host_isolation_exceptions_all', + 'blocklist_all', + 'policy_management_read', + 'host_isolation_all', + 'process_operations_all', + 'actions_log_management_all', + 'file_operations_all', + ], + }, + }, + ], + }; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts index c1c38dcf8b30a..330bcf4589a74 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts @@ -14,36 +14,13 @@ import { CA_CERT_PATH } from '@kbn/dev-utils'; import { ToolingLog } from '@kbn/tooling-log'; import type { KbnClientOptions } from '@kbn/test'; import { KbnClient } from '@kbn/test'; -import type { Role } from '@kbn/security-plugin/common'; +import { EndpointSecurityTestRolesLoader } from './common/role_and_user_loader'; import { METADATA_DATASTREAM } from '../../common/endpoint/constants'; import { EndpointMetadataGenerator } from '../../common/endpoint/data_generators/endpoint_metadata_generator'; import { indexHostsAndAlerts } from '../../common/endpoint/index_data'; import { ANCESTRY_LIMIT, EndpointDocGenerator } from '../../common/endpoint/generate_data'; import { fetchStackVersion, isServerlessKibanaFlavor } from './common/stack_services'; import { ENDPOINT_ALERTS_INDEX, ENDPOINT_EVENTS_INDEX } from './common/constants'; -import { getWithResponseActionsRole } from './common/roles_users/with_response_actions_role'; -import { getNoResponseActionsRole } from './common/roles_users/without_response_actions_role'; -import { getT1Analyst } from './common/roles_users/t1_analyst'; -import { getT2Analyst } from './common/roles_users/t2_analyst'; -import { getEndpointOperationsAnalyst } from './common/roles_users/endpoint_operations_analyst'; -import { getEndpointSecurityPolicyManager } from './common/roles_users/endpoint_security_policy_manager'; -import { getHunter } from './common/roles_users/hunter'; -import { getPlatformEngineer } from './common/roles_users/platform_engineer'; -import { getSocManager } from './common/roles_users/soc_manager'; -import { getThreatIntelligenceAnalyst } from './common/roles_users/threat_intelligence_analyst'; - -const rolesMapping: { [id: string]: Omit } = { - t1Analyst: getT1Analyst(), - t2Analyst: getT2Analyst(), - hunter: getHunter(), - threatIntelligenceAnalyst: getThreatIntelligenceAnalyst(), - socManager: getSocManager(), - platformEngineer: getPlatformEngineer(), - endpointOperationsAnalyst: getEndpointOperationsAnalyst(), - endpointSecurityPolicyManager: getEndpointSecurityPolicyManager(), - withResponseActionsRole: getWithResponseActionsRole(), - noResponseActionsRole: getNoResponseActionsRole(), -}; main(); @@ -67,31 +44,6 @@ async function deleteIndices(indices: string[], client: Client) { } } -async function addRole(kbnClient: KbnClient, role: Role): Promise { - if (!role) { - console.log('No role data given'); - return; - } - - const { name, ...permissions } = role; - const path = `/api/security/role/${name}?createOnly=true`; - - // add role if doesn't exist already - try { - console.log(`Adding ${name} role`); - await kbnClient.request({ - method: 'PUT', - path, - body: permissions, - }); - - return name; - } catch (error) { - console.log(error); - handleErr(error); - } -} - interface UserInfo { username: string; password: string; @@ -422,19 +374,7 @@ async function main() { throw new Error(`Can not use '--rbacUser' option against serverless deployment`); } - // Add roles and users with response actions kibana privileges - for (const role of Object.keys(rolesMapping)) { - const addedRole = await addRole(kbnClient, { - name: role, - ...rolesMapping[role], - }); - if (addedRole) { - logger.info(`Successfully added ${role} role`); - await addUser(client, { username: role, password: 'changeme', roles: [role] }); - } else { - logger.warning(`Failed to add role, ${role}`); - } - } + await loadRbacTestUsers(kbnClient, logger); } const seed = argv.seed || Math.random().toString(); @@ -499,3 +439,12 @@ async function main() { logger.info(`Creating and indexing documents took: ${new Date().getTime() - startTime}ms`); } + +const loadRbacTestUsers = async (kbnClient: KbnClient, logger: ToolingLog): Promise => { + const loadedRoles = await new EndpointSecurityTestRolesLoader(kbnClient, logger).loadAll(); + + logger.info(`Roles and associated users loaded. Login accounts: + ${Object.values(loadedRoles) + .map(({ username, password }) => `${username} / ${password}`) + .join('\n ')}`); +}; From 077be69de1a99335ab72d710496c67835d51d75e Mon Sep 17 00:00:00 2001 From: Andrew Macri Date: Mon, 25 Sep 2023 11:55:19 -0600 Subject: [PATCH 09/12] [Security Solution] [Elastic AI Assistant] LangChain Agents and Tools integration for ES|QL query generation via ELSER (#167097) ## [Security Solution] [Elastic AI Assistant] LangChain Agents and Tools integration for ES|QL query generation via ELSER This PR integrates [LangChain](https://www.langchain.com/) [Agents](https://js.langchain.com/docs/modules/agents/) and [Tools](https://js.langchain.com/docs/modules/agents/tools/) with the [Elastic AI Assistant](https://www.elastic.co/blog/introducing-elastic-ai-assistant). These abstractions enable the LLM to dynamically choose whether or not to query, via [ELSER](https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-elser.html), an [ES|QL](https://www.elastic.co/blog/elasticsearch-query-language-esql) knowledge base. Context from the knowledge base is used to generate `ES|QL` queries, or answer questions about `ES|QL`. Registration of the tool occurs in `x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts`: ```typescript const tools: Tool[] = [ new ChainTool({ name: 'esql-language-knowledge-base', description: 'Call this for knowledge on how to build an ESQL query, or answer questions about the ES|QL query language.', chain, }), ]; ``` The `tools` array above may be updated in future PRs to include, for example, an `ES|QL` query validator endpoint. ### Details The `callAgentExecutor` function in `x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts`: 1. Creates a `RetrievalQAChain` from an `ELSER` backed `ElasticsearchStore`, which serves as a knowledge base for `ES|QL`: ```typescript // ELSER backed ElasticsearchStore for Knowledge Base const esStore = new ElasticsearchStore(esClient, KNOWLEDGE_BASE_INDEX_PATTERN, logger); const chain = RetrievalQAChain.fromLLM(llm, esStore.asRetriever()); ``` 2. Registers the chain as a tool, which may be invoked by the LLM based on its description: ```typescript const tools: Tool[] = [ new ChainTool({ name: 'esql-language-knowledge-base', description: 'Call this for knowledge on how to build an ESQL query, or answer questions about the ES|QL query language.', chain, }), ]; ``` 3. Creates an Agent executor that combines the `tools` above, the `ActionsClientLlm` (an abstraction that calls `actionsClient.execute`), and memory of the previous messages in the conversation: ```typescript const executor = await initializeAgentExecutorWithOptions(tools, llm, { agentType: 'chat-conversational-react-description', memory, verbose: false, }); ``` Note: Set `verbose` above to `true` to for detailed debugging output from LangChain. 4. Calls the `executor`, kicking it off with `latestMessage`: ```typescript await executor.call({ input: latestMessage[0].content }); ``` ### Changes to `x-pack/packages/kbn-elastic-assistant` A client side change was required to the assistant, because the response returned from the agent executor is JSON. This response is parsed on the client in `x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx`: ```typescript return assistantLangChain ? getFormattedMessageContent(result) : result; ``` Client-side parsing of the response only happens when then `assistantLangChain` feature flag is `true`. ## Desk testing Set ```typescript assistantLangChain={true} ``` in `x-pack/plugins/security_solution/public/assistant/provider.tsx` to enable this experimental feature in development environments. Also (optionally) set `verbose` to `true` in the following code in ``x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts``: ```typescript const executor = await initializeAgentExecutorWithOptions(tools, llm, { agentType: 'chat-conversational-react-description', memory, verbose: true, }); ``` After setting the feature flag and optionally enabling verbose debugging output, you may ask the assistant to generate an `ES|QL` query, per the example in the next section. ### Example output When the Elastic AI Assistant is asked: ``` From employees, I want to see the 5 earliest employees (hire_date), I want to display only the month and the year that they were hired in and their employee number (emp_no). Format the date as e.g. "September 2019". Only show the query ``` it replies: ``` Here is the query to get the employee number and the formatted hire date for the 5 earliest employees by hire_date: FROM employees | KEEP emp_no, hire_date | EVAL month_year = DATE_FORMAT(hire_date, "MMMM YYYY") | SORT hire_date | LIMIT 5 ``` Per the screenshot below: ![ESQL_query_via_langchain_agents_and_tools](https://github.com/elastic/kibana/assets/4459398/c5cc75da-f7aa-4a12-9078-ed531f3463e7) The `verbose: true` output from LangChain logged to the console reveals that the prompt sent to the LLM includes text like the following: ``` Assistant can ask the user to use tools to look up information that may be helpful in answering the users original question. The tools the human can use are:\\n\\nesql-language-knowledge-base: Call this for knowledge on how to build an ESQL query, or answer questions about the ES|QL query language. ``` along with instructions for "calling" the tool like a function. The debugging output also reveals the agent selecting the tool, and returning results from ESLR: ``` [agent/action] [1:chain:AgentExecutor] Agent selected action: { "tool": "esql-language-knowledge-base", "toolInput": "Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'.", "log": "```json\n{\n \"action\": \"esql-language-knowledge-base\",\n \"action_input\": \"Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'.\"\n}\n```" } [tool/start] [1:chain:AgentExecutor > 4:tool:ChainTool] Entering Tool run with input: "Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'." [chain/start] [1:chain:AgentExecutor > 4:tool:ChainTool > 5:chain:RetrievalQAChain] Entering Chain run with input: { "query": "Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'." } [retriever/start] [1:chain:AgentExecutor > 4:tool:ChainTool > 5:chain:RetrievalQAChain > 6:retriever:VectorStoreRetriever] Entering Retriever run with input: { "query": "Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'." } [retriever/end] [1:chain:AgentExecutor > 4:tool:ChainTool > 5:chain:RetrievalQAChain > 6:retriever:VectorStoreRetriever] [115ms] Exiting Retriever run with output: { "documents": [ { "pageContent": "[[esql-date_format]]\n=== `DATE_FORMAT`\nReturns a string representation of a date in the provided format. If no format\nis specified, the `yyyy-MM-dd'T'HH:mm:ss.SSSZ` format is used.\n\n[source,esql]\n----\nFROM employees\n| KEEP first_name, last_name, hire_date\n| EVAL hired = DATE_FORMAT(hire_date, \"YYYY-MM-dd\")\n----\n", ``` The documents containing `ES|QL` examples, retrieved from ELSER, are sent back to the LLM to answer the original question, per the abridged output below: ``` [llm/start] [1:chain:AgentExecutor > 4:tool:ChainTool > 5:chain:RetrievalQAChain > 7:chain:StuffDocumentsChain > 8:chain:LLMChain > 9:llm:ActionsClientLlm] Entering LLM run with input: { "prompts": [ "Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer.\n\n[[esql-date_format]]\n=== `DATE_FORMAT`\nReturns a string representation of a date in the provided format. If no format\nis specified, the `yyyy-MM-dd'T'HH:mm:ss.SSSZ` format is used.\n\n[source,esql]\n----\nFROM employees\n| KEEP first_name, last_name, hire_date\n| EVAL hired = DATE_FORMAT(hire_date, \"YYYY-MM-dd\")\n----\n\n\n[[esql-date_trunc]]\n=== `DATE_TRUNC`\nRounds down a date to the closest interval. Intervals can be expressed using the\n<>.\n\n[source,esql]\n----\nFROM employees\n| EVAL year_hired = DATE_TRUNC(1 year, hire_date)\n| STATS count(emp_no) BY year_hired\n| SORT year_hired\n----\n\n\n[[esql-from]]\n=== `FROM`\n\nThe `FROM` source command returns a table with up to 10,000 documents from a\ndata stream, index, ``` ### Complete (verbose) LangChain output from the example The following `verbose: true` output from LangChain below was produced via the example in the previous section: ``` [chain/start] [1:chain:AgentExecutor] Entering Chain run with input: { "input": "\n\n\n\nFrom employees, I want to see the 5 earliest employees (hire_date), I want to display only the month and the year that they were hired in and their employee number (emp_no). Format the date as e.g. \"September 2019\". Only show the query", "chat_history": [] } [chain/start] [1:chain:AgentExecutor > 2:chain:LLMChain] Entering Chain run with input: { "input": "\n\n\n\nFrom employees, I want to see the 5 earliest employees (hire_date), I want to display only the month and the year that they were hired in and their employee number (emp_no). Format the date as e.g. \"September 2019\". Only show the query", "chat_history": [], "agent_scratchpad": [], "stop": [ "Observation:" ] } [llm/start] [1:chain:AgentExecutor > 2:chain:LLMChain > 3:llm:ActionsClientLlm] Entering LLM run with input: { "prompts": [ "[{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"SystemMessage\"],\"kwargs\":{\"content\":\"Assistant is a large language model trained by OpenAI.\\n\\nAssistant is designed to be able to assist with a wide range of tasks, from answering simple questions to providing in-depth explanations and discussions on a wide range of topics. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand.\\n\\nAssistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide accurate and informative responses to a wide range of questions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in discussions and provide explanations and descriptions on a wide range of topics.\\n\\nOverall, Assistant is a powerful system that can help with a wide range of tasks and provide valuable insights and information on a wide range of topics. Whether you need help with a specific question or just want to have a conversation about a particular topic, Assistant is here to assist. However, above all else, all responses must adhere to the format of RESPONSE FORMAT INSTRUCTIONS.\",\"additional_kwargs\":{}}},{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"HumanMessage\"],\"kwargs\":{\"content\":\"TOOLS\\n------\\nAssistant can ask the user to use tools to look up information that may be helpful in answering the users original question. The tools the human can use are:\\n\\nesql-language-knowledge-base: Call this for knowledge on how to build an ESQL query, or answer questions about the ES|QL query language.\\n\\nRESPONSE FORMAT INSTRUCTIONS\\n----------------------------\\n\\nOutput a JSON markdown code snippet containing a valid JSON object in one of two formats:\\n\\n**Option 1:**\\nUse this if you want the human to use a tool.\\nMarkdown code snippet formatted in the following schema:\\n\\n```json\\n{\\n \\\"action\\\": string, // The action to take. Must be one of [esql-language-knowledge-base]\\n \\\"action_input\\\": string // The input to the action. May be a stringified object.\\n}\\n```\\n\\n**Option #2:**\\nUse this if you want to respond directly and conversationally to the human. Markdown code snippet formatted in the following schema:\\n\\n```json\\n{\\n \\\"action\\\": \\\"Final Answer\\\",\\n \\\"action_input\\\": string // You should put what you want to return to use here and make sure to use valid json newline characters.\\n}\\n```\\n\\nFor both options, remember to always include the surrounding markdown code snippet delimiters (begin with \\\"```json\\\" and end with \\\"```\\\")!\\n\\n\\nUSER'S INPUT\\n--------------------\\nHere is the user's input (remember to respond with a markdown code snippet of a json blob with a single action, and NOTHING else):\\n\\n\\n\\n\\n\\nFrom employees, I want to see the 5 earliest employees (hire_date), I want to display only the month and the year that they were hired in and their employee number (emp_no). Format the date as e.g. \\\"September 2019\\\". Only show the query\",\"additional_kwargs\":{}}}]" ] } [llm/end] [1:chain:AgentExecutor > 2:chain:LLMChain > 3:llm:ActionsClientLlm] [3.08s] Exiting LLM run with output: { "generations": [ [ { "text": "```json\n{\n \"action\": \"esql-language-knowledge-base\",\n \"action_input\": \"Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'.\"\n}\n```" } ] ] } [chain/end] [1:chain:AgentExecutor > 2:chain:LLMChain] [3.09s] Exiting Chain run with output: { "text": "```json\n{\n \"action\": \"esql-language-knowledge-base\",\n \"action_input\": \"Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'.\"\n}\n```" } [agent/action] [1:chain:AgentExecutor] Agent selected action: { "tool": "esql-language-knowledge-base", "toolInput": "Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'.", "log": "```json\n{\n \"action\": \"esql-language-knowledge-base\",\n \"action_input\": \"Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'.\"\n}\n```" } [tool/start] [1:chain:AgentExecutor > 4:tool:ChainTool] Entering Tool run with input: "Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'." [chain/start] [1:chain:AgentExecutor > 4:tool:ChainTool > 5:chain:RetrievalQAChain] Entering Chain run with input: { "query": "Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'." } [retriever/start] [1:chain:AgentExecutor > 4:tool:ChainTool > 5:chain:RetrievalQAChain > 6:retriever:VectorStoreRetriever] Entering Retriever run with input: { "query": "Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'." } [retriever/end] [1:chain:AgentExecutor > 4:tool:ChainTool > 5:chain:RetrievalQAChain > 6:retriever:VectorStoreRetriever] [115ms] Exiting Retriever run with output: { "documents": [ { "pageContent": "[[esql-date_format]]\n=== `DATE_FORMAT`\nReturns a string representation of a date in the provided format. If no format\nis specified, the `yyyy-MM-dd'T'HH:mm:ss.SSSZ` format is used.\n\n[source,esql]\n----\nFROM employees\n| KEEP first_name, last_name, hire_date\n| EVAL hired = DATE_FORMAT(hire_date, \"YYYY-MM-dd\")\n----\n", "metadata": { "source": "/Users/andrew.goldstein/Projects/forks/spong/kibana/x-pack/plugins/elastic_assistant/server/knowledge_base/esql/docs/functions/date_format.asciidoc" } }, { "pageContent": "[[esql-date_trunc]]\n=== `DATE_TRUNC`\nRounds down a date to the closest interval. Intervals can be expressed using the\n<>.\n\n[source,esql]\n----\nFROM employees\n| EVAL year_hired = DATE_TRUNC(1 year, hire_date)\n| STATS count(emp_no) BY year_hired\n| SORT year_hired\n----\n", "metadata": { "source": "/Users/andrew.goldstein/Projects/forks/spong/kibana/x-pack/plugins/elastic_assistant/server/knowledge_base/esql/docs/functions/date_trunc.asciidoc" } }, { "pageContent": "[[esql-from]]\n=== `FROM`\n\nThe `FROM` source command returns a table with up to 10,000 documents from a\ndata stream, index, or alias. Each row in the resulting table represents a\ndocument. Each column corresponds to a field, and can be accessed by the name\nof that field.\n\n[source,esql]\n----\nFROM employees\n----\n\nYou can use <> to refer to indices, aliases\nand data streams. This can be useful for time series data, for example to access\ntoday's index:\n\n[source,esql]\n----\nFROM \n----\n\nUse comma-separated lists or wildcards to query multiple data streams, indices,\nor aliases:\n\n[source,esql]\n----\nFROM employees-00001,employees-*\n----\n", "metadata": { "source": "/Users/andrew.goldstein/Projects/forks/spong/kibana/x-pack/plugins/elastic_assistant/server/knowledge_base/esql/docs/source_commands/from.asciidoc" } }, { "pageContent": "[[esql-where]]\n=== `WHERE`\n\nUse `WHERE` to produce a table that contains all the rows from the input table\nfor which the provided condition evaluates to `true`:\n\n[source,esql]\n----\ninclude::{esql-specs}/docs.csv-spec[tag=where]\n----\n\nWhich, if `still_hired` is a boolean field, can be simplified to:\n\n[source,esql]\n----\ninclude::{esql-specs}/docs.csv-spec[tag=whereBoolean]\n----\n\n[discrete]\n==== Operators\n\nRefer to <> for an overview of the supported operators.\n\n[discrete]\n==== Functions\n`WHERE` supports various functions for calculating values. Refer to\n<> for more information.\n\n[source,esql]\n----\ninclude::{esql-specs}/docs.csv-spec[tag=whereFunction]\n----\n", "metadata": { "source": "/Users/andrew.goldstein/Projects/forks/spong/kibana/x-pack/plugins/elastic_assistant/server/knowledge_base/esql/docs/processing_commands/where.asciidoc" } } ] } [chain/start] [1:chain:AgentExecutor > 4:tool:ChainTool > 5:chain:RetrievalQAChain > 7:chain:StuffDocumentsChain] Entering Chain run with input: { "question": "Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'.", "input_documents": [ { "pageContent": "[[esql-date_format]]\n=== `DATE_FORMAT`\nReturns a string representation of a date in the provided format. If no format\nis specified, the `yyyy-MM-dd'T'HH:mm:ss.SSSZ` format is used.\n\n[source,esql]\n----\nFROM employees\n| KEEP first_name, last_name, hire_date\n| EVAL hired = DATE_FORMAT(hire_date, \"YYYY-MM-dd\")\n----\n", "metadata": { "source": "/Users/andrew.goldstein/Projects/forks/spong/kibana/x-pack/plugins/elastic_assistant/server/knowledge_base/esql/docs/functions/date_format.asciidoc" } }, { "pageContent": "[[esql-date_trunc]]\n=== `DATE_TRUNC`\nRounds down a date to the closest interval. Intervals can be expressed using the\n<>.\n\n[source,esql]\n----\nFROM employees\n| EVAL year_hired = DATE_TRUNC(1 year, hire_date)\n| STATS count(emp_no) BY year_hired\n| SORT year_hired\n----\n", "metadata": { "source": "/Users/andrew.goldstein/Projects/forks/spong/kibana/x-pack/plugins/elastic_assistant/server/knowledge_base/esql/docs/functions/date_trunc.asciidoc" } }, { "pageContent": "[[esql-from]]\n=== `FROM`\n\nThe `FROM` source command returns a table with up to 10,000 documents from a\ndata stream, index, or alias. Each row in the resulting table represents a\ndocument. Each column corresponds to a field, and can be accessed by the name\nof that field.\n\n[source,esql]\n----\nFROM employees\n----\n\nYou can use <> to refer to indices, aliases\nand data streams. This can be useful for time series data, for example to access\ntoday's index:\n\n[source,esql]\n----\nFROM \n----\n\nUse comma-separated lists or wildcards to query multiple data streams, indices,\nor aliases:\n\n[source,esql]\n----\nFROM employees-00001,employees-*\n----\n", "metadata": { "source": "/Users/andrew.goldstein/Projects/forks/spong/kibana/x-pack/plugins/elastic_assistant/server/knowledge_base/esql/docs/source_commands/from.asciidoc" } }, { "pageContent": "[[esql-where]]\n=== `WHERE`\n\nUse `WHERE` to produce a table that contains all the rows from the input table\nfor which the provided condition evaluates to `true`:\n\n[source,esql]\n----\ninclude::{esql-specs}/docs.csv-spec[tag=where]\n----\n\nWhich, if `still_hired` is a boolean field, can be simplified to:\n\n[source,esql]\n----\ninclude::{esql-specs}/docs.csv-spec[tag=whereBoolean]\n----\n\n[discrete]\n==== Operators\n\nRefer to <> for an overview of the supported operators.\n\n[discrete]\n==== Functions\n`WHERE` supports various functions for calculating values. Refer to\n<> for more information.\n\n[source,esql]\n----\ninclude::{esql-specs}/docs.csv-spec[tag=whereFunction]\n----\n", "metadata": { "source": "/Users/andrew.goldstein/Projects/forks/spong/kibana/x-pack/plugins/elastic_assistant/server/knowledge_base/esql/docs/processing_commands/where.asciidoc" } } ], "query": "Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'." } [chain/start] [1:chain:AgentExecutor > 4:tool:ChainTool > 5:chain:RetrievalQAChain > 7:chain:StuffDocumentsChain > 8:chain:LLMChain] Entering Chain run with input: { "question": "Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'.", "query": "Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'.", "context": "[[esql-date_format]]\n=== `DATE_FORMAT`\nReturns a string representation of a date in the provided format. If no format\nis specified, the `yyyy-MM-dd'T'HH:mm:ss.SSSZ` format is used.\n\n[source,esql]\n----\nFROM employees\n| KEEP first_name, last_name, hire_date\n| EVAL hired = DATE_FORMAT(hire_date, \"YYYY-MM-dd\")\n----\n\n\n[[esql-date_trunc]]\n=== `DATE_TRUNC`\nRounds down a date to the closest interval. Intervals can be expressed using the\n<>.\n\n[source,esql]\n----\nFROM employees\n| EVAL year_hired = DATE_TRUNC(1 year, hire_date)\n| STATS count(emp_no) BY year_hired\n| SORT year_hired\n----\n\n\n[[esql-from]]\n=== `FROM`\n\nThe `FROM` source command returns a table with up to 10,000 documents from a\ndata stream, index, or alias. Each row in the resulting table represents a\ndocument. Each column corresponds to a field, and can be accessed by the name\nof that field.\n\n[source,esql]\n----\nFROM employees\n----\n\nYou can use <> to refer to indices, aliases\nand data streams. This can be useful for time series data, for example to access\ntoday's index:\n\n[source,esql]\n----\nFROM \n----\n\nUse comma-separated lists or wildcards to query multiple data streams, indices,\nor aliases:\n\n[source,esql]\n----\nFROM employees-00001,employees-*\n----\n\n\n[[esql-where]]\n=== `WHERE`\n\nUse `WHERE` to produce a table that contains all the rows from the input table\nfor which the provided condition evaluates to `true`:\n\n[source,esql]\n----\ninclude::{esql-specs}/docs.csv-spec[tag=where]\n----\n\nWhich, if `still_hired` is a boolean field, can be simplified to:\n\n[source,esql]\n----\ninclude::{esql-specs}/docs.csv-spec[tag=whereBoolean]\n----\n\n[discrete]\n==== Operators\n\nRefer to <> for an overview of the supported operators.\n\n[discrete]\n==== Functions\n`WHERE` supports various functions for calculating values. Refer to\n<> for more information.\n\n[source,esql]\n----\ninclude::{esql-specs}/docs.csv-spec[tag=whereFunction]\n----\n" } [llm/start] [1:chain:AgentExecutor > 4:tool:ChainTool > 5:chain:RetrievalQAChain > 7:chain:StuffDocumentsChain > 8:chain:LLMChain > 9:llm:ActionsClientLlm] Entering LLM run with input: { "prompts": [ "Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer.\n\n[[esql-date_format]]\n=== `DATE_FORMAT`\nReturns a string representation of a date in the provided format. If no format\nis specified, the `yyyy-MM-dd'T'HH:mm:ss.SSSZ` format is used.\n\n[source,esql]\n----\nFROM employees\n| KEEP first_name, last_name, hire_date\n| EVAL hired = DATE_FORMAT(hire_date, \"YYYY-MM-dd\")\n----\n\n\n[[esql-date_trunc]]\n=== `DATE_TRUNC`\nRounds down a date to the closest interval. Intervals can be expressed using the\n<>.\n\n[source,esql]\n----\nFROM employees\n| EVAL year_hired = DATE_TRUNC(1 year, hire_date)\n| STATS count(emp_no) BY year_hired\n| SORT year_hired\n----\n\n\n[[esql-from]]\n=== `FROM`\n\nThe `FROM` source command returns a table with up to 10,000 documents from a\ndata stream, index, or alias. Each row in the resulting table represents a\ndocument. Each column corresponds to a field, and can be accessed by the name\nof that field.\n\n[source,esql]\n----\nFROM employees\n----\n\nYou can use <> to refer to indices, aliases\nand data streams. This can be useful for time series data, for example to access\ntoday's index:\n\n[source,esql]\n----\nFROM \n----\n\nUse comma-separated lists or wildcards to query multiple data streams, indices,\nor aliases:\n\n[source,esql]\n----\nFROM employees-00001,employees-*\n----\n\n\n[[esql-where]]\n=== `WHERE`\n\nUse `WHERE` to produce a table that contains all the rows from the input table\nfor which the provided condition evaluates to `true`:\n\n[source,esql]\n----\ninclude::{esql-specs}/docs.csv-spec[tag=where]\n----\n\nWhich, if `still_hired` is a boolean field, can be simplified to:\n\n[source,esql]\n----\ninclude::{esql-specs}/docs.csv-spec[tag=whereBoolean]\n----\n\n[discrete]\n==== Operators\n\nRefer to <> for an overview of the supported operators.\n\n[discrete]\n==== Functions\n`WHERE` supports various functions for calculating values. Refer to\n<> for more information.\n\n[source,esql]\n----\ninclude::{esql-specs}/docs.csv-spec[tag=whereFunction]\n----\n\n\nQuestion: Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'.\nHelpful Answer:" ] } [llm/end] [1:chain:AgentExecutor > 4:tool:ChainTool > 5:chain:RetrievalQAChain > 7:chain:StuffDocumentsChain > 8:chain:LLMChain > 9:llm:ActionsClientLlm] [2.23s] Exiting LLM run with output: { "generations": [ [ { "text": "FROM employees\n| KEEP emp_no, hire_date\n| EVAL month_year = DATE_FORMAT(hire_date, \"MMMM YYYY\")\n| SORT hire_date\n| LIMIT 5" } ] ] } [chain/end] [1:chain:AgentExecutor > 4:tool:ChainTool > 5:chain:RetrievalQAChain > 7:chain:StuffDocumentsChain > 8:chain:LLMChain] [2.23s] Exiting Chain run with output: { "text": "FROM employees\n| KEEP emp_no, hire_date\n| EVAL month_year = DATE_FORMAT(hire_date, \"MMMM YYYY\")\n| SORT hire_date\n| LIMIT 5" } [chain/end] [1:chain:AgentExecutor > 4:tool:ChainTool > 5:chain:RetrievalQAChain > 7:chain:StuffDocumentsChain] [2.23s] Exiting Chain run with output: { "text": "FROM employees\n| KEEP emp_no, hire_date\n| EVAL month_year = DATE_FORMAT(hire_date, \"MMMM YYYY\")\n| SORT hire_date\n| LIMIT 5" } [chain/end] [1:chain:AgentExecutor > 4:tool:ChainTool > 5:chain:RetrievalQAChain] [2.35s] Exiting Chain run with output: { "text": "FROM employees\n| KEEP emp_no, hire_date\n| EVAL month_year = DATE_FORMAT(hire_date, \"MMMM YYYY\")\n| SORT hire_date\n| LIMIT 5" } [tool/end] [1:chain:AgentExecutor > 4:tool:ChainTool] [2.35s] Exiting Tool run with output: "FROM employees | KEEP emp_no, hire_date | EVAL month_year = DATE_FORMAT(hire_date, "MMMM YYYY") | SORT hire_date | LIMIT 5" [chain/start] [1:chain:AgentExecutor > 10:chain:LLMChain] Entering Chain run with input: { "input": "\n\n\n\nFrom employees, I want to see the 5 earliest employees (hire_date), I want to display only the month and the year that they were hired in and their employee number (emp_no). Format the date as e.g. \"September 2019\". Only show the query", "chat_history": [], "agent_scratchpad": [ { "lc": 1, "type": "constructor", "id": [ "langchain", "schema", "AIMessage" ], "kwargs": { "content": "```json\n{\n \"action\": \"esql-language-knowledge-base\",\n \"action_input\": \"Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'.\"\n}\n```", "additional_kwargs": {} } }, { "lc": 1, "type": "constructor", "id": [ "langchain", "schema", "HumanMessage" ], "kwargs": { "content": "TOOL RESPONSE:\n---------------------\nFROM employees\n| KEEP emp_no, hire_date\n| EVAL month_year = DATE_FORMAT(hire_date, \"MMMM YYYY\")\n| SORT hire_date\n| LIMIT 5\n\nUSER'S INPUT\n--------------------\n\nOkay, so what is the response to my last comment? If using information obtained from the tools you must mention it explicitly without mentioning the tool names - I have forgotten all TOOL RESPONSES! Remember to respond with a markdown code snippet of a json blob with a single action, and NOTHING else.", "additional_kwargs": {} } } ], "stop": [ "Observation:" ] } [llm/start] [1:chain:AgentExecutor > 10:chain:LLMChain > 11:llm:ActionsClientLlm] Entering LLM run with input: { "prompts": [ "[{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"SystemMessage\"],\"kwargs\":{\"content\":\"Assistant is a large language model trained by OpenAI.\\n\\nAssistant is designed to be able to assist with a wide range of tasks, from answering simple questions to providing in-depth explanations and discussions on a wide range of topics. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand.\\n\\nAssistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide accurate and informative responses to a wide range of questions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in discussions and provide explanations and descriptions on a wide range of topics.\\n\\nOverall, Assistant is a powerful system that can help with a wide range of tasks and provide valuable insights and information on a wide range of topics. Whether you need help with a specific question or just want to have a conversation about a particular topic, Assistant is here to assist. However, above all else, all responses must adhere to the format of RESPONSE FORMAT INSTRUCTIONS.\",\"additional_kwargs\":{}}},{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"HumanMessage\"],\"kwargs\":{\"content\":\"TOOLS\\n------\\nAssistant can ask the user to use tools to look up information that may be helpful in answering the users original question. The tools the human can use are:\\n\\nesql-language-knowledge-base: Call this for knowledge on how to build an ESQL query, or answer questions about the ES|QL query language.\\n\\nRESPONSE FORMAT INSTRUCTIONS\\n----------------------------\\n\\nOutput a JSON markdown code snippet containing a valid JSON object in one of two formats:\\n\\n**Option 1:**\\nUse this if you want the human to use a tool.\\nMarkdown code snippet formatted in the following schema:\\n\\n```json\\n{\\n \\\"action\\\": string, // The action to take. Must be one of [esql-language-knowledge-base]\\n \\\"action_input\\\": string // The input to the action. May be a stringified object.\\n}\\n```\\n\\n**Option #2:**\\nUse this if you want to respond directly and conversationally to the human. Markdown code snippet formatted in the following schema:\\n\\n```json\\n{\\n \\\"action\\\": \\\"Final Answer\\\",\\n \\\"action_input\\\": string // You should put what you want to return to use here and make sure to use valid json newline characters.\\n}\\n```\\n\\nFor both options, remember to always include the surrounding markdown code snippet delimiters (begin with \\\"```json\\\" and end with \\\"```\\\")!\\n\\n\\nUSER'S INPUT\\n--------------------\\nHere is the user's input (remember to respond with a markdown code snippet of a json blob with a single action, and NOTHING else):\\n\\n\\n\\n\\n\\nFrom employees, I want to see the 5 earliest employees (hire_date), I want to display only the month and the year that they were hired in and their employee number (emp_no). Format the date as e.g. \\\"September 2019\\\". Only show the query\",\"additional_kwargs\":{}}},{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"AIMessage\"],\"kwargs\":{\"content\":\"```json\\n{\\n \\\"action\\\": \\\"esql-language-knowledge-base\\\",\\n \\\"action_input\\\": \\\"Display the 'emp_no', month and year of the 5 earliest employees by 'hire_date'. Format the date as 'Month Year'.\\\"\\n}\\n```\",\"additional_kwargs\":{}}},{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"HumanMessage\"],\"kwargs\":{\"content\":\"TOOL RESPONSE:\\n---------------------\\nFROM employees\\n| KEEP emp_no, hire_date\\n| EVAL month_year = DATE_FORMAT(hire_date, \\\"MMMM YYYY\\\")\\n| SORT hire_date\\n| LIMIT 5\\n\\nUSER'S INPUT\\n--------------------\\n\\nOkay, so what is the response to my last comment? If using information obtained from the tools you must mention it explicitly without mentioning the tool names - I have forgotten all TOOL RESPONSES! Remember to respond with a markdown code snippet of a json blob with a single action, and NOTHING else.\",\"additional_kwargs\":{}}}]" ] } [llm/end] [1:chain:AgentExecutor > 10:chain:LLMChain > 11:llm:ActionsClientLlm] [6.47s] Exiting LLM run with output: { "generations": [ [ { "text": "```json\n{\n \"action\": \"Final Answer\",\n \"action_input\": \"Here is the query to get the employee number and the formatted hire date for the 5 earliest employees by hire_date:\\n\\nFROM employees\\n| KEEP emp_no, hire_date\\n| EVAL month_year = DATE_FORMAT(hire_date, \\\"MMMM YYYY\\\")\\n| SORT hire_date\\n| LIMIT 5\"\n}\n```" } ] ] } [chain/end] [1:chain:AgentExecutor > 10:chain:LLMChain] [6.47s] Exiting Chain run with output: { "text": "```json\n{\n \"action\": \"Final Answer\",\n \"action_input\": \"Here is the query to get the employee number and the formatted hire date for the 5 earliest employees by hire_date:\\n\\nFROM employees\\n| KEEP emp_no, hire_date\\n| EVAL month_year = DATE_FORMAT(hire_date, \\\"MMMM YYYY\\\")\\n| SORT hire_date\\n| LIMIT 5\"\n}\n```" } [chain/end] [1:chain:AgentExecutor] [11.91s] Exiting Chain run with output: { "output": "Here is the query to get the employee number and the formatted hire date for the 5 earliest employees by hire_date:\n\nFROM employees\n| KEEP emp_no, hire_date\n| EVAL month_year = DATE_FORMAT(hire_date, \"MMMM YYYY\")\n| SORT hire_date\n| LIMIT 5" } ``` --- .../impl/assistant/api.test.tsx | 84 +++++++++++++++++++ .../impl/assistant/api.tsx | 4 +- .../impl/assistant/helpers.test.ts | 43 +++++++++- .../impl/assistant/helpers.ts | 21 +++++ .../execute_custom_llm_chain/index.test.ts | 29 ++++--- .../execute_custom_llm_chain/index.ts | 39 +++++---- .../post_actions_connector_execute.test.ts | 2 +- .../routes/post_actions_connector_execute.ts | 4 +- 8 files changed, 195 insertions(+), 31 deletions(-) diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.test.tsx index 65b8183b60a0b..2f46e99d12b07 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.test.tsx @@ -126,4 +126,88 @@ describe('fetchConnectorExecuteAction', () => { expect(result).toBe('Test response'); }); + + it('returns the value of the action_input property when assistantLangChain is true, and `content` has properly prefixed and suffixed JSON with the action_input property', async () => { + const content = '```json\n{"action_input": "value from action_input"}\n```'; + + (mockHttp.fetch as jest.Mock).mockResolvedValue({ + status: 'ok', + data: { + choices: [ + { + message: { + content, + }, + }, + ], + }, + }); + + const testProps: FetchConnectorExecuteAction = { + assistantLangChain: true, // <-- requires response parsing + http: mockHttp, + messages, + apiConfig, + }; + + const result = await fetchConnectorExecuteAction(testProps); + + expect(result).toBe('value from action_input'); + }); + + it('returns the original content when assistantLangChain is true, and `content` has properly formatted JSON WITHOUT the action_input property', async () => { + const content = '```json\n{"some_key": "some value"}\n```'; + + (mockHttp.fetch as jest.Mock).mockResolvedValue({ + status: 'ok', + data: { + choices: [ + { + message: { + content, + }, + }, + ], + }, + }); + + const testProps: FetchConnectorExecuteAction = { + assistantLangChain: true, // <-- requires response parsing + http: mockHttp, + messages, + apiConfig, + }; + + const result = await fetchConnectorExecuteAction(testProps); + + expect(result).toBe(content); + }); + + it('returns the original when assistantLangChain is true, and `content` is not JSON', async () => { + const content = 'plain text content'; + + (mockHttp.fetch as jest.Mock).mockResolvedValue({ + status: 'ok', + data: { + choices: [ + { + message: { + content, + }, + }, + ], + }, + }); + + const testProps: FetchConnectorExecuteAction = { + assistantLangChain: true, // <-- requires response parsing + http: mockHttp, + messages, + apiConfig, + }; + + const result = await fetchConnectorExecuteAction(testProps); + + expect(result).toBe(content); + }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx index 511b5aa585af0..6d3452b6f7880 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api.tsx @@ -12,6 +12,7 @@ import { HttpSetup, IHttpFetchError } from '@kbn/core-http-browser'; import type { Conversation, Message } from '../assistant_context/types'; import { API_ERROR } from './translations'; import { MODEL_GPT_3_5_TURBO } from '../connectorland/models/model_selector/model_selector'; +import { getFormattedMessageContent } from './helpers'; export interface FetchConnectorExecuteAction { assistantLangChain: boolean; @@ -78,7 +79,8 @@ export const fetchConnectorExecuteAction = async ({ if (data.choices && data.choices.length > 0 && data.choices[0].message.content) { const result = data.choices[0].message.content.trim(); - return result; + + return assistantLangChain ? getFormattedMessageContent(result) : result; } else { return API_ERROR; } diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.test.ts index 69bed887e730e..f2b89a07c319e 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.test.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.test.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { getDefaultConnector, getBlockBotConversation } from './helpers'; +import { + getBlockBotConversation, + getDefaultConnector, + getFormattedMessageContent, +} from './helpers'; import { enterpriseMessaging } from './use_conversation/sample_conversations'; import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; @@ -190,4 +194,41 @@ describe('getBlockBotConversation', () => { expect(result).toBeUndefined(); }); }); + + describe('getFormattedMessageContent', () => { + it('returns the value of the action_input property when `content` has properly prefixed and suffixed JSON with the action_input property', () => { + const content = '```json\n{"action_input": "value from action_input"}\n```'; + + expect(getFormattedMessageContent(content)).toBe('value from action_input'); + }); + + it('returns the original content when `content` has properly formatted JSON WITHOUT the action_input property', () => { + const content = '```json\n{"some_key": "some value"}\n```'; + expect(getFormattedMessageContent(content)).toBe(content); + }); + + it('returns the original content when `content` has improperly formatted JSON', () => { + const content = '```json\n{"action_input": "value from action_input",}\n```'; // <-- the trailing comma makes it invalid + + expect(getFormattedMessageContent(content)).toBe(content); + }); + + it('returns the original content when `content` is missing the prefix', () => { + const content = '{"action_input": "value from action_input"}\n```'; // <-- missing prefix + + expect(getFormattedMessageContent(content)).toBe(content); + }); + + it('returns the original content when `content` is missing the suffix', () => { + const content = '```json\n{"action_input": "value from action_input"}'; // <-- missing suffix + + expect(getFormattedMessageContent(content)).toBe(content); + }); + + it('returns the original content when `content` does NOT contain a JSON string', () => { + const content = 'plain text content'; + + expect(getFormattedMessageContent(content)).toBe(content); + }); + }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts index b01c9001e8319..2b2c5b76851f7 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/helpers.ts @@ -59,3 +59,24 @@ export const getDefaultConnector = ( connectors: Array, Record>> | undefined ): ActionConnector, Record> | undefined => connectors?.length === 1 ? connectors[0] : undefined; + +/** + * When `content` is a JSON string, prefixed with "```json\n" + * and suffixed with "\n```", this function will attempt to parse it and return + * the `action_input` property if it exists. + */ +export const getFormattedMessageContent = (content: string): string => { + const formattedContentMatch = content.match(/```json\n([\s\S]+)\n```/); + + if (formattedContentMatch) { + try { + const parsedContent = JSON.parse(formattedContentMatch[1]); + + return parsedContent.action_input ?? content; + } catch { + // we don't want to throw an error here, so we'll fall back to the original content + } + } + + return content; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.test.ts index be1adbc2e1ce4..67fb3859b9943 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.test.ts @@ -12,7 +12,7 @@ import { ResponseBody } from '../helpers'; import { ActionsClientLlm } from '../llm/actions_client_llm'; import { mockActionResultData } from '../../../__mocks__/action_result_data'; import { langChainMessages } from '../../../__mocks__/lang_chain_messages'; -import { executeCustomLlmChain } from '.'; +import { callAgentExecutor } from '.'; import { loggerMock } from '@kbn/logging-mocks'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; @@ -23,11 +23,18 @@ const mockConversationChain = { }; jest.mock('langchain/chains', () => ({ - ConversationalRetrievalQAChain: { + RetrievalQAChain: { fromLLM: jest.fn().mockImplementation(() => mockConversationChain), }, })); +const mockCall = jest.fn(); +jest.mock('langchain/agents', () => ({ + initializeAgentExecutorWithOptions: jest.fn().mockImplementation(() => ({ + call: mockCall, + })), +})); + const mockConnectorId = 'mock-connector-id'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -42,7 +49,7 @@ const mockActions: ActionsPluginStart = {} as ActionsPluginStart; const mockLogger = loggerMock.create(); const esClientMock = elasticsearchServiceMock.createScopedClusterClient().asCurrentUser; -describe('executeCustomLlmChain', () => { +describe('callAgentExecutor', () => { beforeEach(() => { jest.clearAllMocks(); @@ -52,7 +59,7 @@ describe('executeCustomLlmChain', () => { }); it('creates an instance of ActionsClientLlm with the expected context from the request', async () => { - await executeCustomLlmChain({ + await callAgentExecutor({ actions: mockActions, connectorId: mockConnectorId, esClient: esClientMock, @@ -70,7 +77,7 @@ describe('executeCustomLlmChain', () => { }); it('kicks off the chain with (only) the last message', async () => { - await executeCustomLlmChain({ + await callAgentExecutor({ actions: mockActions, connectorId: mockConnectorId, esClient: esClientMock, @@ -79,15 +86,15 @@ describe('executeCustomLlmChain', () => { request: mockRequest, }); - expect(mockConversationChain.call).toHaveBeenCalledWith({ - question: '\n\nDo you know my name?', + expect(mockCall).toHaveBeenCalledWith({ + input: '\n\nDo you know my name?', }); }); it('kicks off the chain with the expected message when langChainMessages has only one entry', async () => { const onlyOneMessage = [langChainMessages[0]]; - await executeCustomLlmChain({ + await callAgentExecutor({ actions: mockActions, connectorId: mockConnectorId, esClient: esClientMock, @@ -96,13 +103,13 @@ describe('executeCustomLlmChain', () => { request: mockRequest, }); - expect(mockConversationChain.call).toHaveBeenCalledWith({ - question: 'What is my name?', + expect(mockCall).toHaveBeenCalledWith({ + input: 'What is my name?', }); }); it('returns the expected response body', async () => { - const result: ResponseBody = await executeCustomLlmChain({ + const result: ResponseBody = await callAgentExecutor({ actions: mockActions, connectorId: mockConnectorId, esClient: esClientMock, diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts index 5a65b1589b21e..b6a768ad69598 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/execute_custom_llm_chain/index.ts @@ -7,16 +7,18 @@ import { ElasticsearchClient, KibanaRequest, Logger } from '@kbn/core/server'; import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; +import { initializeAgentExecutorWithOptions } from 'langchain/agents'; +import { RetrievalQAChain } from 'langchain/chains'; import { BufferMemory, ChatMessageHistory } from 'langchain/memory'; import { BaseMessage } from 'langchain/schema'; +import { ChainTool, Tool } from 'langchain/tools'; -import { ConversationalRetrievalQAChain } from 'langchain/chains'; +import { ElasticsearchStore } from '../elasticsearch_store/elasticsearch_store'; import { ResponseBody } from '../helpers'; import { ActionsClientLlm } from '../llm/actions_client_llm'; -import { ElasticsearchStore } from '../elasticsearch_store/elasticsearch_store'; import { KNOWLEDGE_BASE_INDEX_PATTERN } from '../../../routes/knowledge_base/constants'; -export const executeCustomLlmChain = async ({ +export const callAgentExecutor = async ({ actions, connectorId, esClient, @@ -34,31 +36,38 @@ export const executeCustomLlmChain = async ({ }): Promise => { const llm = new ActionsClientLlm({ actions, connectorId, request, logger }); - // Chat History Memory: in-memory memory, from client local storage, first message is the system prompt const pastMessages = langChainMessages.slice(0, -1); // all but the last message const latestMessage = langChainMessages.slice(-1); // the last message + const memory = new BufferMemory({ chatHistory: new ChatMessageHistory(pastMessages), - memoryKey: 'chat_history', + memoryKey: 'chat_history', // this is the key expected by https://github.com/langchain-ai/langchainjs/blob/a13a8969345b0f149c1ca4a120d63508b06c52a5/langchain/src/agents/initialize.ts#L166 + inputKey: 'input', + outputKey: 'output', + returnMessages: true, }); // ELSER backed ElasticsearchStore for Knowledge Base const esStore = new ElasticsearchStore(esClient, KNOWLEDGE_BASE_INDEX_PATTERN, logger); + const chain = RetrievalQAChain.fromLLM(llm, esStore.asRetriever()); + + const tools: Tool[] = [ + new ChainTool({ + name: 'esql-language-knowledge-base', + description: + 'Call this for knowledge on how to build an ESQL query, or answer questions about the ES|QL query language.', + chain, + }), + ]; - // Chain w/ chat history memory and knowledge base retriever - const chain = ConversationalRetrievalQAChain.fromLLM(llm, esStore.asRetriever(), { + const executor = await initializeAgentExecutorWithOptions(tools, llm, { + agentType: 'chat-conversational-react-description', memory, - // See `qaChainOptions` from https://js.langchain.com/docs/modules/chains/popular/chat_vector_db - qaChainOptions: { type: 'stuff' }, + verbose: false, }); - await chain.call({ question: latestMessage[0].content }); - // Chain w/ just knowledge base retriever - // const chain = RetrievalQAChain.fromLLM(llm, esStore.asRetriever()); - // await chain.call({ query: latestMessage[0].content }); + await executor.call({ input: latestMessage[0].content }); - // The assistant (on the client side) expects the same response returned - // from the actions framework, so we need to return the same shape of data: return { connector_id: connectorId, data: llm.getActionResultData(), // the response from the actions framework diff --git a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts index 2e6709a6e33c2..57f2b25f5a65f 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.test.ts @@ -20,7 +20,7 @@ jest.mock('../lib/build_response', () => ({ })); jest.mock('../lib/langchain/execute_custom_llm_chain', () => ({ - executeCustomLlmChain: jest.fn().mockImplementation( + callAgentExecutor: jest.fn().mockImplementation( async ({ connectorId, }: { diff --git a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts index 1043f68f0f9c1..bbb1c76e3e579 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/post_actions_connector_execute.ts @@ -20,7 +20,7 @@ import { PostActionsConnectorExecutePathParams, } from '../schemas/post_actions_connector_execute'; import { ElasticAssistantRequestHandlerContext } from '../types'; -import { executeCustomLlmChain } from '../lib/langchain/execute_custom_llm_chain'; +import { callAgentExecutor } from '../lib/langchain/execute_custom_llm_chain'; export const postActionsConnectorExecuteRoute = ( router: IRouter @@ -53,7 +53,7 @@ export const postActionsConnectorExecuteRoute = ( // convert the assistant messages to LangChain messages: const langChainMessages = getLangChainMessages(assistantMessages); - const langChainResponseBody = await executeCustomLlmChain({ + const langChainResponseBody = await callAgentExecutor({ actions, connectorId, esClient, From d941c4a5658769f823fa08fb801303bde8292ff9 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Mon, 25 Sep 2023 13:58:12 -0400 Subject: [PATCH 10/12] unskip serverless dashboard import tests (#167161) Unskips the serverless Dashboard import test. --- .../test_suites/search/dashboards/import_dashboard.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/test_serverless/functional/test_suites/search/dashboards/import_dashboard.ts b/x-pack/test_serverless/functional/test_suites/search/dashboards/import_dashboard.ts index c935ab3f15f83..30d99d112e640 100644 --- a/x-pack/test_serverless/functional/test_suites/search/dashboards/import_dashboard.ts +++ b/x-pack/test_serverless/functional/test_suites/search/dashboards/import_dashboard.ts @@ -27,8 +27,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'svlCommonPage', ]); - // Failing: See https://github.com/elastic/kibana/issues/166573 - describe.skip('Importing an existing dashboard', () => { + describe('Importing an existing dashboard', () => { before(async () => { await PageObjects.svlCommonPage.login(); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); From f23f2f49dbba97350d7159c6d96d0fbb1ef25d84 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Mon, 25 Sep 2023 14:04:31 -0400 Subject: [PATCH 11/12] [Canvas] Remove Kui style sheet import (#167054) removes import of the kui_light stylesheet from Canvas shareable runtime. --- x-pack/plugins/canvas/shareable_runtime/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/plugins/canvas/shareable_runtime/index.ts b/x-pack/plugins/canvas/shareable_runtime/index.ts index aee57c3780503..475989494c574 100644 --- a/x-pack/plugins/canvas/shareable_runtime/index.ts +++ b/x-pack/plugins/canvas/shareable_runtime/index.ts @@ -9,4 +9,3 @@ export * from './api'; import '@kbn/core-apps-server-internal/assets/legacy_light_theme.css'; import '../public/style/index.scss'; import '@elastic/eui/dist/eui_theme_light.css'; -import '@kbn/ui-framework/dist/kui_light.css'; From c20d177a036be73d7b1180dc17e644afa260994f Mon Sep 17 00:00:00 2001 From: Kyle Pollich Date: Mon, 25 Sep 2023 14:05:03 -0400 Subject: [PATCH 12/12] [Fleet] Increase package install max timeout + add concurrency control to rollovers (#166775) Fixes https://github.com/elastic/kibana/issues/166761 Ref https://github.com/elastic/kibana/issues/162772 ## Summary - Increase overall timeout for waiting to retry "stuck" installations from 1 minute to 30 minutes - Add `pMap` concurrency control limiting concurrent `putMapping` + `rollover` requests to mitigate ES load --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/fleet/common/constants/epm.ts | 2 +- .../epm/elasticsearch/template/template.ts | 25 ++- .../epm/packages/_install_package.test.ts | 144 +++++++++++++++++- .../services/epm/packages/_install_package.ts | 32 ++-- 4 files changed, 179 insertions(+), 24 deletions(-) diff --git a/x-pack/plugins/fleet/common/constants/epm.ts b/x-pack/plugins/fleet/common/constants/epm.ts index 52cb24271afa5..3548fee93fbf2 100644 --- a/x-pack/plugins/fleet/common/constants/epm.ts +++ b/x-pack/plugins/fleet/common/constants/epm.ts @@ -9,7 +9,7 @@ import { ElasticsearchAssetType, KibanaAssetType } from '../types/models'; export const PACKAGES_SAVED_OBJECT_TYPE = 'epm-packages'; export const ASSETS_SAVED_OBJECT_TYPE = 'epm-packages-assets'; -export const MAX_TIME_COMPLETE_INSTALL = 60000; +export const MAX_TIME_COMPLETE_INSTALL = 30 * 60 * 1000; // 30 minutes export const FLEET_SYSTEM_PACKAGE = 'system'; export const FLEET_ELASTIC_AGENT_PACKAGE = 'elastic_agent'; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 3e9363fa9828f..5682749d7e381 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -11,6 +11,8 @@ import type { MappingTypeMapping, } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import pMap from 'p-map'; + import type { Field, Fields } from '../../fields/field'; import type { RegistryDataStream, @@ -729,15 +731,22 @@ const updateAllDataStreams = async ( esClient: ElasticsearchClient, logger: Logger ): Promise => { - const updatedataStreamPromises = indexNameWithTemplates.map((templateEntry) => { - return updateExistingDataStream({ - esClient, - logger, - dataStreamName: templateEntry.dataStreamName, - }); - }); - await Promise.all(updatedataStreamPromises); + await pMap( + indexNameWithTemplates, + (templateEntry) => { + return updateExistingDataStream({ + esClient, + logger, + dataStreamName: templateEntry.dataStreamName, + }); + }, + { + // Limit concurrent putMapping/rollover requests to avoid overhwhelming ES cluster + concurrency: 20, + } + ); }; + const updateExistingDataStream = async ({ dataStreamName, esClient, diff --git a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.test.ts index 3c308b8e85b0a..b7fe0d95310ef 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.test.ts @@ -5,11 +5,21 @@ * 2.0. */ -import type { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/server'; +import type { + SavedObjectsClientContract, + ElasticsearchClient, + SavedObject, +} from '@kbn/core/server'; import { savedObjectsClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; import { loggerMock } from '@kbn/logging-mocks'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; +import { ConcurrentInstallOperationError } from '../../../errors'; + +import type { Installation } from '../../../../common'; + +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../common'; + import { appContextService } from '../../app_context'; import { createAppContextStartContractMock } from '../../../mocks'; import { saveArchiveEntries } from '../archive/storage'; @@ -29,7 +39,9 @@ jest.mock('../elasticsearch/datastream_ilm/install'); import { updateCurrentWriteIndices } from '../elasticsearch/template/template'; import { installKibanaAssetsAndReferences } from '../kibana/assets/install'; -import { installIndexTemplatesAndPipelines } from './install'; +import { MAX_TIME_COMPLETE_INSTALL } from '../../../../common/constants'; + +import { installIndexTemplatesAndPipelines, restartInstallation } from './install'; import { _installPackage } from './_install_package'; @@ -69,9 +81,7 @@ describe('_installPackage', () => { jest.mocked(saveArchiveEntries).mockResolvedValue({ saved_objects: [], }); - }); - afterEach(async () => { - appContextService.stop(); + jest.mocked(restartInstallation).mockReset(); }); it('handles errors from installKibanaAssets', async () => { // force errors from this function @@ -226,4 +236,128 @@ describe('_installPackage', () => { expect(installILMPolicy).toBeCalled(); expect(installIlmForDataStream).toBeCalled(); }); + + describe('when package is stuck in `installing`', () => { + afterEach(() => {}); + const mockInstalledPackageSo: SavedObject = { + id: 'mocked-package', + attributes: { + name: 'test-package', + version: '1.0.0', + install_status: 'installing', + install_version: '1.0.0', + install_started_at: new Date().toISOString(), + install_source: 'registry', + verification_status: 'verified', + installed_kibana: [] as any, + installed_es: [] as any, + es_index_patterns: {}, + }, + type: PACKAGES_SAVED_OBJECT_TYPE, + references: [], + }; + + beforeEach(() => { + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + disableProxies: false, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + }, + }, + }) + ); + }); + + describe('timeout reached', () => { + it('restarts installation', async () => { + await _installPackage({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + paths: [], + packageInfo: { + name: mockInstalledPackageSo.attributes.name, + version: mockInstalledPackageSo.attributes.version, + title: mockInstalledPackageSo.attributes.name, + } as any, + installedPkg: { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date( + Date.now() - MAX_TIME_COMPLETE_INSTALL * 2 + ).toISOString(), + }, + }, + }); + + expect(restartInstallation).toBeCalled(); + }); + }); + + describe('timeout not reached', () => { + describe('force flag not provided', () => { + it('throws concurrent installation error if force flag is not provided', async () => { + expect( + _installPackage({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + paths: [], + packageInfo: { + name: mockInstalledPackageSo.attributes.name, + version: mockInstalledPackageSo.attributes.version, + title: mockInstalledPackageSo.attributes.name, + } as any, + installedPkg: { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }, + }) + ).rejects.toThrowError(ConcurrentInstallOperationError); + }); + }); + + describe('force flag provided', () => { + it('restarts installation', async () => { + await _installPackage({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + paths: [], + packageInfo: { + name: mockInstalledPackageSo.attributes.name, + version: mockInstalledPackageSo.attributes.version, + title: mockInstalledPackageSo.attributes.name, + } as any, + installedPkg: { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }, + force: true, + }); + + expect(restartInstallation).toBeCalled(); + }); + }); + }); + }); }); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts index 337cf59bbd613..3bfc74bf68968 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts @@ -99,18 +99,30 @@ export async function _installPackage({ try { // if some installation already exists if (installedPkg) { + const isStatusInstalling = installedPkg.attributes.install_status === 'installing'; + const hasExceededTimeout = + Date.now() - Date.parse(installedPkg.attributes.install_started_at) < + MAX_TIME_COMPLETE_INSTALL; + // if the installation is currently running, don't try to install // instead, only return already installed assets - if ( - installedPkg.attributes.install_status === 'installing' && - Date.now() - Date.parse(installedPkg.attributes.install_started_at) < - MAX_TIME_COMPLETE_INSTALL - ) { - throw new ConcurrentInstallOperationError( - `Concurrent installation or upgrade of ${pkgName || 'unknown'}-${ - pkgVersion || 'unknown' - } detected, aborting.` - ); + if (isStatusInstalling && hasExceededTimeout) { + // If this is a forced installation, ignore the timeout and restart the installation anyway + if (force) { + await restartInstallation({ + savedObjectsClient, + pkgName, + pkgVersion, + installSource, + verificationResult, + }); + } else { + throw new ConcurrentInstallOperationError( + `Concurrent installation or upgrade of ${pkgName || 'unknown'}-${ + pkgVersion || 'unknown' + } detected, aborting.` + ); + } } else { // if no installation is running, or the installation has been running longer than MAX_TIME_COMPLETE_INSTALL // (it might be stuck) update the saved object and proceed