diff --git a/x-pack/platform/plugins/shared/fleet/common/constants/epm.ts b/x-pack/platform/plugins/shared/fleet/common/constants/epm.ts index f9dbe5a0eab22..769fb3a22535a 100644 --- a/x-pack/platform/plugins/shared/fleet/common/constants/epm.ts +++ b/x-pack/platform/plugins/shared/fleet/common/constants/epm.ts @@ -87,6 +87,8 @@ export const HIDDEN_API_REFERENCE_PACKAGES = [ FLEET_SYNTHETICS_PACKAGE, ]; +export const EXCLUDED_FROM_PACKAGE_POLICY_COPY_PACKAGES = [FLEET_ENDPOINT_PACKAGE]; + export const autoUpgradePoliciesPackages = [ FLEET_APM_PACKAGE, FLEET_SYNTHETICS_PACKAGE, diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx index 49af462dc96f5..51847fad49740 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx @@ -97,6 +97,24 @@ const breadcrumbGetters: { }), }, ], + copy_integration: ({ policyName, policyId }) => [ + BASE_BREADCRUMB, + { + href: pagePathGetters.policies()[1], + text: i18n.translate('xpack.fleet.breadcrumbs.policiesPageTitle', { + defaultMessage: 'Agent policies', + }), + }, + { + href: pagePathGetters.policy_details({ policyId })[1], + text: policyName, + }, + { + text: i18n.translate('xpack.fleet.breadcrumbs.copyPackagePolicyPageTitle', { + defaultMessage: 'Copy integration', + }), + }, + ], upgrade_package_policy: ({ policyName, policyId }) => [ BASE_BREADCRUMB, { diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/copy_package_policy_page/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/copy_package_policy_page/index.tsx new file mode 100644 index 0000000000000..b55dfa9d4f301 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/copy_package_policy_page/index.tsx @@ -0,0 +1,141 @@ +/* + * 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, { useMemo, memo } from 'react'; +import { useRouteMatch, useLocation } from 'react-router-dom'; + +import { EuiEmptyPrompt, EuiFlexGroup } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import styled from '@emotion/styled'; + +import { EXCLUDED_FROM_PACKAGE_POLICY_COPY_PACKAGES } from '../../../../../../common/constants'; + +import { useGetOnePackagePolicy } from '../../../../integrations/hooks'; +import { Loading } from '../../../components'; +import type { EditPackagePolicyFrom } from '../create_package_policy_page/types'; + +import { CreatePackagePolicySinglePage } from '../create_package_policy_page/single_page_layout'; +import { useBreadcrumbs, useGetOneAgentPolicy } from '../../../hooks'; +import { useBreadcrumbs as useIntegrationsBreadcrumbs } from '../../../../integrations/hooks'; + +const ContentWrapper = styled(EuiFlexGroup)` + height: 100%; + margin: 0 auto; +`; + +const IntegrationsBreadcrumb = memo<{ + pkgTitle: string; + policyName: string; + pkgkey: string; +}>(({ pkgTitle, policyName, pkgkey }) => { + useIntegrationsBreadcrumbs('integration_policy_copy', { policyName, pkgTitle, pkgkey }); + return null; +}); + +const PoliciesBreadcrumb: React.FunctionComponent<{ + policyName: string; + policyId: string; +}> = ({ policyName, policyId }) => { + useBreadcrumbs('copy_integration', { policyName, policyId }); + return null; +}; + +const InstalledIntegrationsBreadcrumb = memo<{ + policyName: string; +}>(({ policyName }) => { + useIntegrationsBreadcrumbs('integration_policy_copy_from_installed', { policyName }); + return null; +}); + +export const CopyPackagePolicyPage = memo(() => { + const { + params: { packagePolicyId, policyId }, + } = useRouteMatch<{ packagePolicyId: string; policyId?: string }>(); + + const packagePolicy = useGetOnePackagePolicy(packagePolicyId); + const agentPolicy = useGetOneAgentPolicy(policyId); + + const packagePolicyData = useMemo(() => { + if (packagePolicy.data?.item) { + return { + ...packagePolicy.data.item, + name: 'copy-' + packagePolicy.data.item.name, + }; + } + }, [packagePolicy.data?.item]); + + // Parse the 'from' query parameter to determine navigation after save + const { search } = useLocation(); + + const from = useMemo(() => { + const qs = new URLSearchParams(search); + const qsFrom = (qs.get('from') as EditPackagePolicyFrom | null) ?? 'fleet-policy-list'; + + if (qsFrom === 'fleet-policy-list') { + return 'copy-from-fleet-policy-list'; + } else if (qsFrom === 'installed-integrations') { + return 'copy-from-installed-integrations'; + } else { + return 'copy-from-integrations-policy-list'; + } + }, [search]); + + if (packagePolicy.isLoading || !packagePolicy.data) { + return ( + <> + + + ); + } + + const breadcrumb = + from === 'copy-from-fleet-policy-list' && policyId ? ( + + ) : from === 'copy-from-installed-integrations' ? ( + + ) : ( + + ); + + const pkgName = packagePolicy.data?.item?.package?.name; + + if (pkgName && EXCLUDED_FROM_PACKAGE_POLICY_COPY_PACKAGES.includes(pkgName)) { + return ( + + {breadcrumb} + + } + color="danger" + iconType="error" + /> + + ); + } + + return ( + <> + {breadcrumb} + + + ); +}); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_stream.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_stream.tsx index 160c888eaa691..5fbfa7f6a5087 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_stream.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_stream.tsx @@ -173,7 +173,10 @@ export const PackagePolicyInputStreamConfig = memo( return ( <> - + diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/hooks/navigation.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/hooks/navigation.tsx index 6cd5d2bb5087a..1c8b487c0bb50 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/hooks/navigation.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/hooks/navigation.tsx @@ -8,6 +8,7 @@ import { useCallback, useMemo, useEffect, useRef } from 'react'; import type { ApplicationStart } from '@kbn/core-application-browser'; +import { splitPkgKey } from '../../../../../../../common/services'; import { PLUGIN_ID, INTEGRATIONS_PLUGIN_ID } from '../../../../constants'; import { pkgKeyFromPackageInfo } from '../../../../services'; import { useStartServices, useLink, useIntraAppState } from '../../../../hooks'; @@ -48,7 +49,11 @@ export const useCancelAddPackagePolicy = (params: UseCancelParams) => { if (routeState && routeState.onCancelUrl) { return routeState.onCancelUrl; } - return from === 'policy' && agentPolicyId + if (from === 'installed-integrations' || from === 'copy-from-installed-integrations') { + return `${getHref('integrations_installed', {})}?viewPolicies=${splitPkgKey(pkgkey).pkgName}`; + } + + return (from === 'policy' || from === 'copy-from-fleet-policy-list') && agentPolicyId ? getHref('policy_details', { policyId: agentPolicyId, }) diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/components/layout.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/components/layout.tsx index 94d38ac74a569..1fc5bc6bf2076 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/components/layout.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/components/layout.tsx @@ -22,7 +22,12 @@ import { import { useAgentless } from '../hooks/setup_technology'; import { WithHeaderLayout } from '../../../../../layouts'; -import type { AgentPolicy, PackageInfo, RegistryPolicyTemplate } from '../../../../../types'; +import type { + AgentPolicy, + PackageInfo, + PackagePolicy, + RegistryPolicyTemplate, +} from '../../../../../types'; import { PackageIcon } from '../../../../../components'; import type { EditPackagePolicyFrom } from '../../types'; @@ -41,6 +46,7 @@ export const CreatePackagePolicySinglePageLayout: React.FunctionComponent<{ agentPolicy?: AgentPolicy; packageInfo?: PackageInfo; integrationInfo?: RegistryPolicyTemplate; + defaultPolicyData?: Partial; 'data-test-subj'?: string; tabs?: Array<{ title: string; @@ -57,6 +63,7 @@ export const CreatePackagePolicySinglePageLayout: React.FunctionComponent<{ packageInfo, integrationInfo, children, + defaultPolicyData, 'data-test-subj': dataTestSubj, tabs = [], }) => { @@ -72,6 +79,16 @@ export const CreatePackagePolicySinglePageLayout: React.FunctionComponent<{ [from] ); + const isCopy = useMemo( + () => + [ + 'copy-from-fleet-policy-list', + 'copy-from-integrations-policy-list', + 'copy-from-installed-integrations', + ].includes(from), + [from] + ); + const pageTitle = useMemo(() => { if ((isAdd || isEdit || isUpgrade) && packageInfo) { let pageTitleText = ( @@ -152,6 +169,22 @@ export const CreatePackagePolicySinglePageLayout: React.FunctionComponent<{ ); } + if (isCopy) { + return ( + +

+ +

+
+ ); + } + return (

@@ -168,9 +201,11 @@ export const CreatePackagePolicySinglePageLayout: React.FunctionComponent<{ integrationInfo?.name, integrationInfo?.title, packageInfo, + defaultPolicyData?.name, isAdd, isEdit, isUpgrade, + isCopy, ]); const pageDescription = useMemo(() => { diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx index 3e7419bf502af..f79138a4a05b5 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { load } from 'js-yaml'; -import { isEqual, omit } from 'lodash'; +import { isEqual, omit, pick } from 'lodash'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiLink } from '@elastic/eui'; @@ -284,6 +284,7 @@ export function useOnSubmit({ setNewAgentPolicy, setSelectedPolicyTab, isAddIntegrationFlyout, + defaultPolicyData, }: { packageInfo?: PackageInfo; newAgentPolicy: NewAgentPolicy; @@ -296,6 +297,7 @@ export function useOnSubmit({ setNewAgentPolicy: (policy: NewAgentPolicy) => void; setSelectedPolicyTab: (tab: SelectedPolicyTab) => void; isAddIntegrationFlyout?: boolean; + defaultPolicyData?: Partial; }) { const { notifications, docLinks } = useStartServices(); const { spaceId } = useFleetStatus(); @@ -419,6 +421,29 @@ export function useOnSubmit({ integrationToEnable ); + if (defaultPolicyData) { + Object.assign( + basePackagePolicy, + pick( + defaultPolicyData, + 'name', + 'description', + 'namespace', + 'policy_ids', + 'output_id', + 'cloud_connector_id', + 'cloud_connector_name', + 'inputs', + 'vars', + 'elasticsearch', + 'overrides', + 'supports_agentless', + 'supports_cloud_connector', + 'additional_datastreams_permissions' + ) + ); + } + // Set the package policy with the fetched package updatePackagePolicy(basePackagePolicy); setIsInitialized(true); @@ -440,6 +465,8 @@ export function useOnSubmit({ integration, setIntegration, isAddIntegrationFlyout, + defaultPolicyData, + setSelectedPolicyTab, ]); useEffect(() => { diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx index d3e9f1d8b61c3..95a63df298465 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx @@ -115,6 +115,8 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ integration, pkgLabel, addIntegrationFlyoutProps, + defaultPolicyData, + noBreadcrumb, }) => { const { agents: { enabled: isFleetEnabled }, @@ -144,7 +146,9 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ }); const [selectedPolicyTab, setSelectedPolicyTab] = useState( - queryParamsPolicyId ? SelectedPolicyTab.EXISTING : SelectedPolicyTab.NEW + queryParamsPolicyId || (defaultPolicyData?.policy_ids?.length ?? 0) > 0 + ? SelectedPolicyTab.EXISTING + : SelectedPolicyTab.NEW ); const { @@ -217,6 +221,7 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ setNewAgentPolicy, setSelectedPolicyTab, isAddIntegrationFlyout, + defaultPolicyData, }); if (addIntegrationFlyoutProps?.agentPolicy) { @@ -361,8 +366,17 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ agentPolicies, packageInfo, integrationInfo, + defaultPolicyData, }), - [agentPolicies, cancelClickHandler, cancelUrl, from, integrationInfo, packageInfo] + [ + agentPolicies, + cancelClickHandler, + cancelUrl, + from, + integrationInfo, + packageInfo, + defaultPolicyData, + ] ); const stepSelectAgentPolicy = useMemo( @@ -378,7 +392,13 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ packageInfo={packageInfo} setHasAgentPolicyError={setHasAgentPolicyError} updateSelectedTab={updateSelectedPolicyTab} - selectedAgentPolicyIds={queryParamsPolicyId ? [queryParamsPolicyId] : []} + selectedAgentPolicyIds={ + queryParamsPolicyId + ? [queryParamsPolicyId] + : defaultPolicyData?.policy_ids + ? defaultPolicyData?.policy_ids + : [] + } /> ), [ @@ -392,6 +412,7 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ updateSelectedPolicyTab, queryParamsPolicyId, setHasAgentPolicyError, + defaultPolicyData, ] ); @@ -725,7 +746,7 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ onCancel={() => navigateAddAgentHelp(savedPackagePolicy)} /> )} - {packageInfo && !addIntegrationFlyoutProps && ( + {packageInfo && !addIntegrationFlyoutProps && !noBreadcrumb && ( ; + noBreadcrumb?: boolean; addIntegrationFlyoutProps?: { selectIntegrationStep: EuiStepProps; onSubmitCompleted: () => void; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/index.tsx index abce53e850d4c..2c1dfdeb2a56e 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/index.tsx @@ -17,6 +17,7 @@ import { AgentPolicyListPage } from './list_page'; import { AgentPolicyDetailsPage } from './details_page'; import { EditPackagePolicyPage } from './edit_package_policy_page'; import { UpgradePackagePolicyPage } from './upgrade_package_policy_page'; +import { CopyPackagePolicyPage } from './copy_package_policy_page'; export const AgentPolicyApp: React.FunctionComponent = () => { useBreadcrumbs('policies'); @@ -26,6 +27,9 @@ export const AgentPolicyApp: React.FunctionComponent = () => { + + + diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/hooks/use_breadcrumbs.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/hooks/use_breadcrumbs.tsx index f730cd7ece2e4..cb8f76f9c8541 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/hooks/use_breadcrumbs.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/hooks/use_breadcrumbs.tsx @@ -59,6 +59,19 @@ const breadcrumbGetters: { }, { text: policyName }, ], + integration_policy_copy: ({ pkgTitle, pkgkey, policyName }) => [ + BASE_BREADCRUMB, + { + href: pagePathGetters.integration_details_policies({ pkgkey })[1], + text: pkgTitle, + }, + { text: policyName }, + { + text: i18n.translate('xpack.fleet.breadcrumbs.copyPackagePolicyPageTitle', { + defaultMessage: 'Copy integration', + }), + }, + ], integration_policy_upgrade: ({ pkgTitle, pkgkey, policyName }) => [ BASE_BREADCRUMB, { @@ -82,6 +95,21 @@ const breadcrumbGetters: { }, { text: policyName }, ], + integration_policy_copy_from_installed: ({ policyName }) => [ + BASE_BREADCRUMB, + { + href: pagePathGetters.integrations_installed({})[1], + text: i18n.translate('xpack.fleet.breadcrumbs.installedIntegrationsPageTitle', { + defaultMessage: 'Installed integrations', + }), + }, + { text: policyName }, + { + text: i18n.translate('xpack.fleet.breadcrumbs.copyPackagePolicyPageTitle', { + defaultMessage: 'Copy integration', + }), + }, + ], }; export function useBreadcrumbs(page: Page, values: DynamicPagePathValues = {}) { diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/index.tsx index 303951eaf9461..bb0fba95bb639 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/index.tsx @@ -13,6 +13,8 @@ import { EuiSkeletonText } from '@elastic/eui'; import { INTEGRATIONS_ROUTING_PATHS } from '../../constants'; import { IntegrationsStateContextProvider, useBreadcrumbs, useStartServices } from '../../hooks'; +import { CopyPackagePolicyPage } from '../../../fleet/sections/agent_policy/copy_package_policy_page'; + import { EPMHomePage } from './screens/home'; import { Detail } from './screens/detail'; import { Policy } from './screens/policy'; @@ -28,6 +30,9 @@ export const EPMApp: React.FunctionComponent = () => { + + + diff --git a/x-pack/platform/plugins/shared/fleet/public/components/package_policy_actions_menu.test.tsx b/x-pack/platform/plugins/shared/fleet/public/components/package_policy_actions_menu.test.tsx index 7f2c5e1a638db..e5c0aa9da17f4 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/package_policy_actions_menu.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/package_policy_actions_menu.test.tsx @@ -254,4 +254,36 @@ describe('PackagePolicyActionsMenu', () => { }); }); }); + + it('Should disable Copy integration for excluded packages', async () => { + const agentPolicies = createMockAgentPolicies(); + const packagePolicy = createMockPackagePolicy({ + package: { + name: 'endpoint', + version: '1.0.0', + title: 'Elastic Defend', + }, + }); + const { utils } = renderMenu({ agentPolicies, packagePolicy }); + await waitFor(() => { + const copyButton = utils.getByTestId('PackagePolicyActionsCopyItem'); + expect(copyButton).toBeDisabled(); + }); + }); + + it('Should enable Copy integration for non-excluded packages', async () => { + const agentPolicies = createMockAgentPolicies(); + const packagePolicy = createMockPackagePolicy({ + package: { + name: 'some-other-package', + version: '1.0.0', + title: 'Some Other Package', + }, + }); + const { utils } = renderMenu({ agentPolicies, packagePolicy }); + await waitFor(() => { + const copyButton = utils.getByTestId('PackagePolicyActionsCopyItem'); + expect(copyButton).not.toBeDisabled(); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/fleet/public/components/package_policy_actions_menu.tsx b/x-pack/platform/plugins/shared/fleet/public/components/package_policy_actions_menu.tsx index f17c0f82d40dc..72297331aae5d 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/package_policy_actions_menu.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/package_policy_actions_menu.tsx @@ -9,6 +9,7 @@ import React, { useMemo, useState } from 'react'; import { EuiContextMenuItem, EuiPortal } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { EXCLUDED_FROM_PACKAGE_POLICY_COPY_PACKAGES } from '../../common/constants'; import type { AgentPolicy, InMemoryPackagePolicy } from '../types'; import { useAgentPolicyRefresh, useAuthz, useLink } from '../hooks'; import { policyHasFleetServer } from '../services'; @@ -126,13 +127,40 @@ export const PackagePolicyActionsMenu: React.FunctionComponent<{ , ] : []), - // FIXME: implement Copy package policy action - // {}} key="packagePolicyCopy"> - // - // , + + ) : undefined + } + href={ + isOrphanedPolicy || isAgentlessPolicy + ? getHref('integration_policy_copy', { + policyId: agentPolicy?.id || '', + packagePolicyId: packagePolicy.id, + }) + (from ? `?from=${from}` : '') + : getHref('copy_integration', { + policyId: agentPolicy?.id || '', + packagePolicyId: packagePolicy.id, + }) + (from ? `?from=${from}` : '') + } + data-test-subj="PackagePolicyActionsCopyItem" + icon="copy" + key="packagePolicyCopy" + > + + , ]; if (!agentPolicy || !agentPolicyIsManaged || agentPolicy?.supports_agentless) { diff --git a/x-pack/platform/plugins/shared/fleet/public/constants/page_paths.ts b/x-pack/platform/plugins/shared/fleet/public/constants/page_paths.ts index 51f948d07d674..9ae272bb056d7 100644 --- a/x-pack/platform/plugins/shared/fleet/public/constants/page_paths.ts +++ b/x-pack/platform/plugins/shared/fleet/public/constants/page_paths.ts @@ -37,11 +37,14 @@ export type DynamicPage = | 'integration_details_api_reference' | 'integration_details_configs' | 'integration_policy_edit' + | 'integration_policy_copy' | 'integration_policy_upgrade' | 'integration_policy_edit_from_installed' + | 'integration_policy_copy_from_installed' | 'policy_details' | 'add_integration_to_policy' | 'edit_integration' + | 'copy_integration' | 'upgrade_package_policy' | 'agent_list' | 'agent_details' @@ -76,6 +79,7 @@ export const FLEET_ROUTING_PATHS = { policy_details: '/policies/:policyId/:tabId?', policy_details_settings: '/policies/:policyId/settings', edit_integration: '/policies/:policyId/edit-integration/:packagePolicyId', + copy_integration: '/policies/:policyId/copy-integration/:packagePolicyId', upgrade_package_policy: '/policies/:policyId/upgrade-package-policy/:packagePolicyId', enrollment_tokens: '/enrollment-tokens', uninstall_tokens: '/uninstall-tokens', @@ -113,6 +117,7 @@ export const INTEGRATIONS_ROUTING_PATHS = { integration_details_api_reference: '/detail/:pkgkey/api-reference', integration_details_language_clients: '/language_clients/:pkgkey/overview', integration_policy_edit: '/edit-integration/:packagePolicyId', + integration_policy_copy: '/copy-integration/:packagePolicyId', integration_policy_upgrade: '/edit-integration/:packagePolicyId', }; @@ -209,6 +214,10 @@ export const pagePathGetters: { INTEGRATIONS_BASE_PATH, `/edit-integration/${packagePolicyId}`, ], + integration_policy_copy: ({ packagePolicyId }) => [ + INTEGRATIONS_BASE_PATH, + `/copy-integration/${packagePolicyId}`, + ], // Upgrades happen on the same edit form, just with a flag set. Separate page record here // allows us to set different breadcrumbs for upgrades when needed. integration_policy_upgrade: ({ packagePolicyId }) => [ @@ -220,6 +229,11 @@ export const pagePathGetters: { INTEGRATIONS_BASE_PATH, `/edit-integration/${packagePolicyId}`, ], + // Used for breadcrumbs when copying a policy from the installed integrations tab + integration_policy_copy_from_installed: ({ packagePolicyId }) => [ + INTEGRATIONS_BASE_PATH, + `/edit-integration/${packagePolicyId}`, + ], // This route allows rendering custom language integration pages registered in the language_client plugin integration_details_language_clients: ({ pkgkey }) => [ INTEGRATIONS_BASE_PATH, @@ -253,6 +267,10 @@ export const pagePathGetters: { FLEET_BASE_PATH, `/policies/${policyId}/edit-integration/${packagePolicyId}`, ], + copy_integration: ({ policyId, packagePolicyId }) => [ + FLEET_BASE_PATH, + `/policies/${policyId}/copy-integration/${packagePolicyId}`, + ], upgrade_package_policy: ({ policyId, packagePolicyId }) => [ FLEET_BASE_PATH, `/policies/${policyId}/upgrade-package-policy/${packagePolicyId}`, diff --git a/x-pack/platform/plugins/shared/fleet/public/hooks/use_request/agent_policy.ts b/x-pack/platform/plugins/shared/fleet/public/hooks/use_request/agent_policy.ts index 98cd260fdc613..4195543cd20cd 100644 --- a/x-pack/platform/plugins/shared/fleet/public/hooks/use_request/agent_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/public/hooks/use_request/agent_policy.ts @@ -63,15 +63,20 @@ export const useGetAgentPoliciesQuery = ( export const useBulkGetAgentPoliciesQuery = ( ids: string[], - options?: { full?: boolean; ignoreMissing?: boolean } + options?: { full?: boolean; ignoreMissing?: boolean; enabled?: boolean } ) => { - return useQuery(['agentPolicies', ids], () => - sendRequestForRq({ - path: agentPolicyRouteService.getBulkGetPath(), - method: 'post', - body: JSON.stringify({ ids, full: options?.full }), - version: API_VERSIONS.public.v1, - }) + return useQuery( + ['agentPolicies', ids], + () => + sendRequestForRq({ + path: agentPolicyRouteService.getBulkGetPath(), + method: 'post', + body: JSON.stringify({ ids, full: options?.full }), + version: API_VERSIONS.public.v1, + }), + { + enabled: options?.enabled, + } ); }; diff --git a/x-pack/platform/plugins/shared/fleet/test/scout/ui/fixtures/page_objects/copy_integration_page.ts b/x-pack/platform/plugins/shared/fleet/test/scout/ui/fixtures/page_objects/copy_integration_page.ts new file mode 100644 index 0000000000000..b35941a494cd3 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/test/scout/ui/fixtures/page_objects/copy_integration_page.ts @@ -0,0 +1,64 @@ +/* + * 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 ScoutPage } from '@kbn/scout'; + +export class CopyIntegrationPage { + constructor(private readonly page: ScoutPage) {} + + async navigateTo(agentPolicyId: string, packagePolicyId: string) { + await this.page.gotoApp(`fleet/policies/${agentPolicyId}/copy-integration/${packagePolicyId}`); + } + + async waitForPageToLoad() { + await this.page.waitForLoadingIndicatorHidden(); + } + + getPackagePolicyNameInput() { + return this.page.testSubj.locator('packagePolicyNameInput'); + } + + getSaveButton() { + return this.page.testSubj.locator('createPackagePolicySaveButton'); + } + + getAgentPolicySelect() { + return this.page.testSubj.locator('agentPolicyMultiSelect'); + } + + async fillPackagePolicyName(name: string) { + const input = this.getPackagePolicyNameInput(); + await input.clear(); + await input.fill(name); + } + + /** + * Get the multi-text input for a variable by its field name. + * The selector is based on the field label lowercased with spaces replaced by dashes. + */ + getMultiTextInput(fieldName: string) { + return this.page.testSubj.locator(`multiTextInput-${fieldName}`); + } + + /** + * Get the first row of a multi-text input (the actual input element) + */ + getMultiTextInputRow(dataset: string, fieldName: string, index: number = 0) { + return this.page.testSubj + .locator(`streamOptions.inputStreams.${dataset}`) + .getByTestId(`multiTextInput-${fieldName}`) + .getByTestId(`multiTextInputRow-${index}`); + } + + getSuccessPostInstallAddAgentModal() { + return this.page.testSubj.locator('postInstallAddAgentModal'); + } + + async clickSaveButton() { + await this.getSaveButton().click(); + } +} diff --git a/x-pack/platform/plugins/shared/fleet/test/scout/ui/fixtures/page_objects/index.ts b/x-pack/platform/plugins/shared/fleet/test/scout/ui/fixtures/page_objects/index.ts index 6ee2b2c50f7a8..5c3d69e218254 100644 --- a/x-pack/platform/plugins/shared/fleet/test/scout/ui/fixtures/page_objects/index.ts +++ b/x-pack/platform/plugins/shared/fleet/test/scout/ui/fixtures/page_objects/index.ts @@ -9,6 +9,7 @@ import type { PageObjects, ScoutPage } from '@kbn/scout'; import { createLazyPageObject } from '@kbn/scout'; import { BrowseIntegrationPage } from './browse_integrations_page'; +import { CopyIntegrationPage } from './copy_integration_page'; import { CreateIntegrationLandingPage } from './create_integration_landing_page'; import { FleetHomePage } from './fleet_home'; import { IntegrationHomePage } from './integration_home'; @@ -18,12 +19,14 @@ export interface StreamsPageObjects extends PageObjects { createIntegrationLanding: CreateIntegrationLandingPage; fleetHome: FleetHomePage; integrationHome: IntegrationHomePage; + copyIntegration: CopyIntegrationPage; } export function extendPageObjects(pageObjects: PageObjects, page: ScoutPage): StreamsPageObjects { return { ...pageObjects, browseIntegrations: createLazyPageObject(BrowseIntegrationPage, page), + copyIntegration: createLazyPageObject(CopyIntegrationPage, page), createIntegrationLanding: createLazyPageObject(CreateIntegrationLandingPage, page), fleetHome: createLazyPageObject(FleetHomePage, page), integrationHome: createLazyPageObject(IntegrationHomePage, page), diff --git a/x-pack/platform/plugins/shared/fleet/test/scout/ui/tests/copy_integration.spec.ts b/x-pack/platform/plugins/shared/fleet/test/scout/ui/tests/copy_integration.spec.ts new file mode 100644 index 0000000000000..63fca8a184497 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/test/scout/ui/tests/copy_integration.spec.ts @@ -0,0 +1,154 @@ +/* + * 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/scout'; + +import { test } from '../fixtures'; + +test.describe('Copy integration', { tag: ['@ess'] }, () => { + const testAgentPolicyName = 'Test Agent Policy for Copy'; + const packagePolicyName = 'nginx-test-copy'; + let agentPolicyId: string; + let packagePolicyId: string; + + test.beforeAll(async ({ kbnClient }) => { + const agentPolicyResponse = await kbnClient.request<{ item: { id: string } }>({ + method: 'POST', + path: '/api/fleet/agent_policies', + body: { + name: testAgentPolicyName, + namespace: 'default', + monitoring_enabled: ['logs', 'metrics'], + }, + }); + agentPolicyId = agentPolicyResponse.data.item.id; + + const packagePolicyResponse = await kbnClient.request<{ item: { id: string } }>({ + method: 'POST', + path: '/api/fleet/package_policies', + query: { + format: 'simplified', + }, + body: { + policy_ids: [agentPolicyId], + package: { + name: 'nginx', + version: '2.3.2', + }, + name: packagePolicyName, + description: '', + namespace: 'default', + inputs: { + 'nginx-logfile': { + enabled: true, + streams: { + 'nginx.access': { + enabled: true, + vars: { + paths: ['/var/log/nginx/test123-access.log*'], + tags: ['nginx-access'], + preserve_original_event: false, + ignore_older: '72h', + }, + }, + 'nginx.error': { + enabled: false, + vars: { + paths: ['/var/log/nginx/error.log*'], + tags: ['nginx-error'], + preserve_original_event: false, + ignore_older: '72h', + }, + }, + }, + }, + 'nginx-nginx/metrics': { + enabled: false, + vars: { + hosts: ['http://127.0.0.1:80'], + }, + streams: { + 'nginx.stubstatus': { + enabled: false, + vars: { + period: '10s', + server_status_path: '/nginx_status', + tags: ['nginx-stubstatus'], + }, + }, + }, + }, + }, + }, + }); + packagePolicyId = packagePolicyResponse.data.item.id; + }); + + test.afterAll(async ({ kbnClient }) => { + if (packagePolicyId) { + await kbnClient.request({ + method: 'POST', + path: '/api/fleet/package_policies/delete', + body: { + packagePolicyIds: [packagePolicyId], + }, + }); + } + + const packagePoliciesResponse = await kbnClient.request<{ + items: Array<{ id: string; name: string }>; + }>({ + method: 'GET', + path: '/api/fleet/package_policies', + query: { + kuery: `ingest-package-policies.name:${packagePolicyName}*`, + }, + }); + for (const policy of packagePoliciesResponse.data.items) { + if (policy.name.startsWith(packagePolicyName)) { + await kbnClient.request({ + method: 'POST', + path: '/api/fleet/package_policies/delete', + body: { + packagePolicyIds: [policy.id], + }, + }); + } + } + + if (agentPolicyId) { + await kbnClient.request({ + method: 'POST', + path: '/api/fleet/agent_policies/delete', + body: { + agentPolicyId, + }, + }); + } + }); + + test('can copy nginx package policy', async ({ browserAuth, pageObjects }) => { + await browserAuth.loginAsPrivilegedUser(); + const { copyIntegration } = pageObjects; + + await copyIntegration.navigateTo(agentPolicyId, packagePolicyId); + await copyIntegration.waitForPageToLoad(); + + const nameInput = copyIntegration.getPackagePolicyNameInput(); + await expect(nameInput).toBeVisible(); + + await expect(nameInput).toHaveValue(`copy-${packagePolicyName}`); + + const pathsInput = copyIntegration.getMultiTextInputRow('nginx.access', 'paths'); + await expect(pathsInput).toHaveValue('/var/log/nginx/test123-access.log*'); + + await expect(copyIntegration.getSaveButton()).toBeVisible(); + await copyIntegration.clickSaveButton(); + + await expect(copyIntegration.getSuccessPostInstallAddAgentModal()).toBeVisible(); + }); +});