diff --git a/x-pack/plugins/apm/common/apm_saved_object_constants.ts b/x-pack/plugins/apm/common/apm_saved_object_constants.ts index c8e8f893ad3ec..7d9e571242afe 100644 --- a/x-pack/plugins/apm/common/apm_saved_object_constants.ts +++ b/x-pack/plugins/apm/common/apm_saved_object_constants.ts @@ -15,3 +15,7 @@ export const APM_INDICES_SAVED_OBJECT_ID = 'apm-indices'; // APM telemetry export const APM_TELEMETRY_SAVED_OBJECT_TYPE = 'apm-telemetry'; export const APM_TELEMETRY_SAVED_OBJECT_ID = 'apm-telemetry'; + +// APM Server schema +export const APM_SERVER_SCHEMA_SAVED_OBJECT_TYPE = 'apm-server-schema'; +export const APM_SERVER_SCHEMA_SAVED_OBJECT_ID = 'apm-server-schema'; diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/blog-rocket-720x420.png b/x-pack/plugins/apm/public/components/app/Settings/schema/blog-rocket-720x420.png new file mode 100644 index 0000000000000..c444e7641e3fb Binary files /dev/null and b/x-pack/plugins/apm/public/components/app/Settings/schema/blog-rocket-720x420.png differ diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/confirm_switch_modal.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/confirm_switch_modal.tsx new file mode 100644 index 0000000000000..47e83fa079e63 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/confirm_switch_modal.tsx @@ -0,0 +1,143 @@ +/* + * 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, { useState } from 'react'; +import { + EuiConfirmModal, + EuiCallOut, + EuiCheckbox, + EuiSpacer, + EuiCodeBlock, + htmlIdGenerator, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ElasticDocsLink } from '../../../shared/Links/ElasticDocsLink'; + +interface Props { + onConfirm: () => void; + onCancel: () => void; + unsupportedConfigs: Array<{ key: string; value: string }>; + isLoading: boolean; +} +export function ConfirmSwitchModal({ + onConfirm, + onCancel, + unsupportedConfigs, + isLoading, +}: Props) { + const [isConfirmChecked, setIsConfirmChecked] = useState(false); + const hasUnsupportedConfigs = !!unsupportedConfigs.length; + return ( + +

+ {i18n.translate('xpack.apm.settings.schema.confirm.descriptionText', { + defaultMessage: + 'If you have custom dashboards, machine learning jobs, or source maps that use classic APM indices, you must reconfigure them for data streams. Stack monitoring is not currently supported with Fleet-managed APM.', + })} +

+ {!hasUnsupportedConfigs && ( +

+ {i18n.translate( + 'xpack.apm.settings.schema.confirm.unsupportedConfigs.descriptionText', + { + defaultMessage: `Compatible custom apm-server.yml user settings will be moved to Fleet Server settings for you. We'll let you know which settings are incompatible before removing them.`, + } + )} +

+ )} + +

+ {i18n.translate( + 'xpack.apm.settings.schema.confirm.irreversibleWarning.message', + { + defaultMessage: `It might temporarily affect your APM data collection while the migration is in progress. The process of migrating should only take a few minutes.`, + } + )} +

+
+ + {hasUnsupportedConfigs && ( + <> + + + {unsupportedConfigs + .map(({ key, value }) => `${key}: ${JSON.stringify(value)}`) + .join('\n')} + +

+ + {i18n.translate( + 'xpack.apm.settings.schema.confirm.apmServerSettingsCloudLinkText', + { defaultMessage: 'Go to APM Server settings in Cloud' } + )} + +

+
+ + + )} +

+ { + setIsConfirmChecked(e.target.checked); + }} + disabled={isLoading} + /> +

+
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/index.tsx new file mode 100644 index 0000000000000..fee072470f05a --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/index.tsx @@ -0,0 +1,137 @@ +/* + * 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, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { NotificationsStart } from 'kibana/public'; +import { SchemaOverview } from './schema_overview'; +import { ConfirmSwitchModal } from './confirm_switch_modal'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { + callApmApi, + APIReturnType, +} from '../../../../services/rest/createCallApmApi'; +import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; + +type FleetMigrationCheckResponse = APIReturnType<'GET /api/apm/fleet/migration_check'>; + +export function Schema() { + const { toasts } = useApmPluginContext().core.notifications; + const [isSwitchActive, setIsSwitchActive] = useState(false); + const [isLoadingMigration, setIsLoadingMigration] = useState(false); + const [isLoadingConfirmation, setIsLoadingConfirmation] = useState(false); + const [unsupportedConfigs, setUnsupportedConfigs] = useState< + Array<{ key: string; value: any }> + >([]); + + const { + refetch, + data = {} as FleetMigrationCheckResponse, + status, + } = useFetcher( + (callApi) => callApi({ endpoint: 'GET /api/apm/fleet/migration_check' }), + [], + { preservePreviousData: false } + ); + const isLoading = status !== FETCH_STATUS.SUCCESS; + const cloudApmMigrationEnabled = !!data.cloud_apm_migration_enabled; + const hasCloudAgentPolicy = !!data.has_cloud_agent_policy; + const hasCloudApmPackagePolicy = !!data.has_cloud_apm_package_policy; + const hasRequiredRole = !!data.has_required_role; + return ( + <> + { + setIsLoadingConfirmation(true); + const unsupported = await getUnsupportedApmServerConfigs(toasts); + if (!unsupported) { + setIsLoadingConfirmation(false); + return; + } + setUnsupportedConfigs(unsupported); + setIsLoadingConfirmation(false); + setIsSwitchActive(true); + }} + isMigrated={hasCloudApmPackagePolicy} + isLoading={isLoading} + isLoadingConfirmation={isLoadingConfirmation} + cloudApmMigrationEnabled={cloudApmMigrationEnabled} + hasCloudAgentPolicy={hasCloudAgentPolicy} + hasRequiredRole={hasRequiredRole} + /> + {isSwitchActive && ( + { + setIsLoadingMigration(true); + const apmPackagePolicy = await createCloudApmPackagePolicy(toasts); + if (!apmPackagePolicy) { + setIsLoadingMigration(false); + return; + } + setIsSwitchActive(false); + refetch(); + }} + onCancel={() => { + if (isLoadingMigration) { + return; + } + setIsSwitchActive(false); + }} + unsupportedConfigs={unsupportedConfigs} + /> + )} + + ); +} + +async function getUnsupportedApmServerConfigs( + toasts: NotificationsStart['toasts'] +) { + try { + const { unsupported } = await callApmApi({ + endpoint: 'GET /api/apm/fleet/apm_server_schema/unsupported', + signal: null, + }); + return unsupported; + } catch (error) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.apm.settings.unsupportedConfigs.errorToast.title', + { + defaultMessage: 'Unable to fetch APM Server settings', + } + ), + text: error.body?.message || error.message, + }); + } +} + +async function createCloudApmPackagePolicy( + toasts: NotificationsStart['toasts'] +) { + try { + const { + cloud_apm_package_policy: cloudApmPackagePolicy, + } = await callApmApi({ + endpoint: 'POST /api/apm/fleet/cloud_apm_package_policy', + signal: null, + }); + return cloudApmPackagePolicy; + } catch (error) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.apm.settings.createApmPackagePolicy.errorToast.title', + { + defaultMessage: + 'Unable to create APM package policy on cloud agent policy', + } + ), + text: error.body?.message || error.message, + }); + } +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx new file mode 100644 index 0000000000000..1005c09cb11b0 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx @@ -0,0 +1,355 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiText, + EuiCard, + EuiIcon, + EuiButton, + EuiCallOut, + EuiLoadingSpinner, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ElasticDocsLink } from '../../../shared/Links/ElasticDocsLink'; +import rocketLaunchGraphic from './blog-rocket-720x420.png'; +import { APMLink } from '../../../shared/Links/apm/APMLink'; +import { useFleetCloudAgentPolicyHref } from '../../../shared/Links/kibana'; + +interface Props { + onSwitch: () => void; + isMigrated: boolean; + isLoading: boolean; + isLoadingConfirmation: boolean; + cloudApmMigrationEnabled: boolean; + hasCloudAgentPolicy: boolean; + hasRequiredRole: boolean; +} +export function SchemaOverview({ + onSwitch, + isMigrated, + isLoading, + isLoadingConfirmation, + cloudApmMigrationEnabled, + hasCloudAgentPolicy, + hasRequiredRole, +}: Props) { + const fleetCloudAgentPolicyHref = useFleetCloudAgentPolicyHref(); + const isDisabled = + !cloudApmMigrationEnabled || !hasCloudAgentPolicy || !hasRequiredRole; + + if (isLoading) { + return ( + <> + + + + + + ); + } + + if (isMigrated) { + return ( + <> + + + + + + } + title={i18n.translate('xpack.apm.settings.schema.success.title', { + defaultMessage: 'Data streams successfully setup!', + })} + description={i18n.translate( + 'xpack.apm.settings.schema.success.description', + { + defaultMessage: + 'Your APM integration is now setup and ready to receive data from your currently instrumented agents. Feel free to review the policies applied to your integtration.', + } + )} + footer={ +
+ + {i18n.translate( + 'xpack.apm.settings.schema.success.viewIntegrationInFleet.buttonText', + { defaultMessage: 'View the APM integration in Fleet' } + )} + + + +

+ + {i18n.translate( + 'xpack.apm.settings.schema.success.returnText.serviceInventoryLink', + { defaultMessage: 'Service inventory' } + )} + + ), + }} + /> +

+
+
+ } + /> +
+ +
+ + ); + } + + return ( + <> + + + + + } + title={i18n.translate( + 'xpack.apm.settings.schema.migrate.classicIndices.title', + { defaultMessage: 'Classic APM indices' } + )} + display="subdued" + description={i18n.translate( + 'xpack.apm.settings.schema.migrate.classicIndices.description', + { + defaultMessage: + 'You are currently using classic APM indices for your data. This data schema is going away and is being replaced by data streams in Elastic Stack version 8.0.', + } + )} + footer={ +
+ +

+ {i18n.translate( + 'xpack.apm.settings.schema.migrate.classicIndices.currentSetup', + { defaultMessage: 'Current setup' } + )} +

+
+
+ } + /> +
+ + + rocket launch + + } + title={i18n.translate( + 'xpack.apm.settings.schema.migrate.dataStreams.title', + { defaultMessage: 'Data streams' } + )} + description={i18n.translate( + 'xpack.apm.settings.schema.migrate.dataStreams.description', + { + defaultMessage: + 'Going forward, any newly ingested data gets stored in data streams. Previously ingested data remains in classic APM indices. The APM and UX apps will continue to support both indices.', + } + )} + footer={ +
+ + + {i18n.translate( + 'xpack.apm.settings.schema.migrate.dataStreams.buttonText', + { defaultMessage: 'Switch to data streams' } + )} + + +
+ } + onClick={onSwitch} + isDisabled={isDisabled} + /> +
+ +
+ + + + + +

+ {i18n.translate( + 'xpack.apm.settings.schema.migrate.calloutNote.message', + { + defaultMessage: + 'If you have custom dashboards, machine learning jobs, or source maps that use classic APM indices, you must reconfigure them for data streams.', + } + )} +

+
+
+ +
+ + ); +} + +export function SchemaOverviewHeading() { + return ( + <> + + + {i18n.translate( + 'xpack.apm.settings.schema.descriptionText.irreversibleEmphasisText', + { defaultMessage: 'irreversible' } + )} + + ), + superuserEmphasis: ( + + {i18n.translate( + 'xpack.apm.settings.schema.descriptionText.superuserEmphasisText', + { defaultMessage: 'superuser' } + )} + + ), + dataStreamsDocLink: ( + + {i18n.translate( + 'xpack.apm.settings.schema.descriptionText.dataStreamsDocLinkText', + { defaultMessage: 'data streams' } + )} + + ), + }} + /> + + + + + +

+ {i18n.translate('xpack.apm.settings.schema.title', { + defaultMessage: 'Schema', + })} +

+
+
+
+ + + ); +} + +function getDisabledReason({ + cloudApmMigrationEnabled, + hasCloudAgentPolicy, + hasRequiredRole, +}: { + cloudApmMigrationEnabled: boolean; + hasCloudAgentPolicy: boolean; + hasRequiredRole: boolean; +}) { + const reasons: string[] = []; + if (!cloudApmMigrationEnabled) { + reasons.push( + i18n.translate( + 'xpack.apm.settings.schema.disabledReason.cloudApmMigrationEnabled', + { defaultMessage: 'Cloud migration is not enabled' } + ) + ); + } + if (!hasCloudAgentPolicy) { + reasons.push( + i18n.translate( + 'xpack.apm.settings.schema.disabledReason.hasCloudAgentPolicy', + { defaultMessage: 'Cloud agent policy does not exist' } + ) + ); + } + if (!hasRequiredRole) { + reasons.push( + i18n.translate( + 'xpack.apm.settings.schema.disabledReason.hasRequiredRole', + { defaultMessage: 'User does not have superuser role' } + ) + ); + } + if (reasons.length) { + return ( + + {reasons.map((reasonText, index) => ( +
  • - {reasonText}
  • + ))} + + ), + }} + /> + ); + } +} diff --git a/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx b/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx index 36580d38e660d..5214489c9142b 100644 --- a/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx +++ b/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx @@ -19,6 +19,7 @@ import { AgentConfigurations } from '../app/Settings/AgentConfigurations'; import { AnomalyDetection } from '../app/Settings/anomaly_detection'; import { ApmIndices } from '../app/Settings/ApmIndices'; import { CustomizeUI } from '../app/Settings/CustomizeUI'; +import { Schema } from '../app/Settings/schema'; import { TraceLink } from '../app/TraceLink'; import { TransactionLink } from '../app/transaction_link'; import { TransactionDetails } from '../app/transaction_details'; @@ -250,6 +251,14 @@ function SettingsCustomizeUI() { ); } +function SettingsSchema() { + return ( + + + + ); +} + export function EditAgentConfigurationRouteView(props: RouteComponentProps) { const { search } = props.history.location; @@ -315,6 +324,10 @@ const SettingsCustomizeUITitle = i18n.translate( 'xpack.apm.views.settings.customizeUI.title', { defaultMessage: 'Customize app' } ); +const SettingsSchemaTitle = i18n.translate( + 'xpack.apm.views.settings.schema.title', + { defaultMessage: 'Schema' } +); const SettingsAnomalyDetectionTitle = i18n.translate( 'xpack.apm.views.settings.anomalyDetection.title', { defaultMessage: 'Anomaly detection' } @@ -395,6 +408,12 @@ export const apmRouteConfig: APMRouteDefinition[] = [ component: SettingsCustomizeUI, breadcrumb: SettingsCustomizeUITitle, }, + { + exact: true, + path: '/settings/schema', + component: SettingsSchema, + breadcrumb: SettingsSchemaTitle, + }, { exact: true, path: '/settings/anomaly-detection', diff --git a/x-pack/plugins/apm/public/components/routing/templates/settings_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/settings_template.tsx index 0e610722a76e7..a76b464731513 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/settings_template.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/settings_template.tsx @@ -20,7 +20,8 @@ type Tab = NonNullable[0] & { | 'agent-configurations' | 'anomaly-detection' | 'apm-indices' - | 'customize-ui'; + | 'customize-ui' + | 'schema'; hidden?: boolean; }; @@ -100,6 +101,13 @@ function getTabs({ }), href: getAPMHref({ basePath, path: `/settings/apm-indices`, search }), }, + { + key: 'schema', + label: i18n.translate('xpack.apm.settings.schema', { + defaultMessage: 'Schema', + }), + href: getAPMHref({ basePath, path: `/settings/schema`, search }), + }, ]; return tabs diff --git a/x-pack/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx index 6d9d38328968a..5a7cc4623ea7b 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx @@ -10,7 +10,13 @@ import React from 'react'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; // union type constisting of valid guide sections that we link to -type DocsSection = '/apm/get-started' | '/x-pack' | '/apm/server' | '/kibana'; +type DocsSection = + | '/apm/get-started' + | '/x-pack' + | '/apm/server' + | '/kibana' + | '/elasticsearch/reference' + | '/cloud'; interface Props extends EuiLinkAnchorProps { section: DocsSection; @@ -20,7 +26,7 @@ interface Props extends EuiLinkAnchorProps { export function ElasticDocsLink({ section, path, children, ...rest }: Props) { const { docLinks } = useApmPluginContext().core; const baseUrl = docLinks.ELASTIC_WEBSITE_URL; - const version = docLinks.DOC_LINK_VERSION; + const version = section === '/cloud' ? 'current' : docLinks.DOC_LINK_VERSION; const href = `${baseUrl}guide/en${section}/${version}${path}`; return typeof children === 'function' ? ( diff --git a/x-pack/plugins/apm/public/components/shared/Links/kibana.ts b/x-pack/plugins/apm/public/components/shared/Links/kibana.ts index f974a7e29ae47..bfb7cf849f567 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/kibana.ts +++ b/x-pack/plugins/apm/public/components/shared/Links/kibana.ts @@ -17,3 +17,12 @@ export function useUpgradeAssistantHref() { return getUpgradeAssistantHref(core.http.basePath); } + +export function useFleetCloudAgentPolicyHref() { + const { + core: { + http: { basePath }, + }, + } = useApmPluginContext(); + return basePath.prepend('/app/fleet#/policies/policy-elastic-agent-on-cloud'); +} diff --git a/x-pack/plugins/apm/server/index.test.ts b/x-pack/plugins/apm/server/index.test.ts index 006a21b597458..226dfd6e95bd3 100644 --- a/x-pack/plugins/apm/server/index.test.ts +++ b/x-pack/plugins/apm/server/index.test.ts @@ -25,6 +25,7 @@ describe('mergeConfigs', () => { ui: { enabled: false }, enabled: true, metricsInterval: 2000, + agent: { migrations: { enabled: true } }, } as APMXPackConfig; expect(mergeConfigs(apmOssConfig, apmConfig)).toEqual({ @@ -35,6 +36,7 @@ describe('mergeConfigs', () => { 'apm_oss.transactionIndices': 'apm-*-transaction-*', 'xpack.apm.metricsInterval': 2000, 'xpack.apm.ui.enabled': false, + 'xpack.apm.agent.migrations.enabled': true, }); }); @@ -47,7 +49,7 @@ describe('mergeConfigs', () => { fleetMode: true, } as APMOSSConfig; - const apmConfig = { ui: {} } as APMXPackConfig; + const apmConfig = { ui: {}, agent: { migrations: {} } } as APMXPackConfig; expect(mergeConfigs(apmOssConfig, apmConfig)).toEqual({ 'apm_oss.errorIndices': 'logs-apm*,apm-*-error-*', @@ -66,7 +68,7 @@ describe('mergeConfigs', () => { fleetMode: false, } as APMOSSConfig; - const apmConfig = { ui: {} } as APMXPackConfig; + const apmConfig = { ui: {}, agent: { migrations: {} } } as APMXPackConfig; expect(mergeConfigs(apmOssConfig, apmConfig)).toEqual({ 'apm_oss.errorIndices': 'apm-*-error-*', diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 9ab56c1a303ea..413efcdb78812 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -49,6 +49,11 @@ export const config = { maxServiceEnvironments: schema.number({ defaultValue: 100 }), maxServiceSelection: schema.number({ defaultValue: 50 }), profilingEnabled: schema.boolean({ defaultValue: false }), + agent: schema.object({ + migrations: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), + }), }), }; @@ -94,6 +99,7 @@ export function mergeConfigs( 'xpack.apm.searchAggregatedTransactions': apmConfig.searchAggregatedTransactions, 'xpack.apm.metricsInterval': apmConfig.metricsInterval, + 'xpack.apm.agent.migrations.enabled': apmConfig.agent.migrations.enabled, }; if (apmOssConfig.fleetMode) { diff --git a/x-pack/plugins/apm/server/lib/fleet/create_cloud_apm_package_policy.ts b/x-pack/plugins/apm/server/lib/fleet/create_cloud_apm_package_policy.ts new file mode 100644 index 0000000000000..9e3095a8d1bca --- /dev/null +++ b/x-pack/plugins/apm/server/lib/fleet/create_cloud_apm_package_policy.ts @@ -0,0 +1,50 @@ +/* + * 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 { + ElasticsearchClient, + SavedObjectsClientContract, + Logger, +} from 'kibana/server'; +import { + APM_SERVER_SCHEMA_SAVED_OBJECT_TYPE, + APM_SERVER_SCHEMA_SAVED_OBJECT_ID, +} from '../../../common/apm_saved_object_constants'; +import { APMPluginStartDependencies } from '../../types'; +import { getApmPackagePolicyDefinition } from './get_apm_package_policy_definition'; + +export async function createCloudApmPackgePolicy({ + fleetPluginStart, + savedObjectsClient, + esClient, + logger, +}: { + fleetPluginStart: NonNullable; + savedObjectsClient: SavedObjectsClientContract; + esClient: ElasticsearchClient; + logger: Logger; +}) { + const { attributes } = await savedObjectsClient.get( + APM_SERVER_SCHEMA_SAVED_OBJECT_TYPE, + APM_SERVER_SCHEMA_SAVED_OBJECT_ID + ); + const apmServerSchema: Record = JSON.parse( + (attributes as { schemaJson: string }).schemaJson + ); + const apmPackagePolicyDefinition = getApmPackagePolicyDefinition( + apmServerSchema + ); + logger.info(`Fleet migration on Cloud - apmPackagePolicy create start`); + const apmPackagePolicy = await fleetPluginStart.packagePolicyService.create( + savedObjectsClient, + esClient, + apmPackagePolicyDefinition, + { force: true, bumpRevision: true } + ); + logger.info(`Fleet migration on Cloud - apmPackagePolicy create end`); + return apmPackagePolicy; +} diff --git a/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts b/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts new file mode 100644 index 0000000000000..fb88a092cb265 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts @@ -0,0 +1,176 @@ +/* + * 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 { + POLICY_ELASTIC_AGENT_ON_CLOUD, + APM_PACKAGE_NAME, +} from './get_cloud_apm_package_policy'; + +export function getApmPackagePolicyDefinition( + apmServerSchema: Record +) { + return { + name: 'apm', + namespace: 'default', + enabled: true, + policy_id: POLICY_ELASTIC_AGENT_ON_CLOUD, + output_id: '', + inputs: [ + { + type: 'apm', + enabled: true, + streams: [], + vars: getApmPackageInputVars(apmServerSchema), + }, + ], + package: { + name: APM_PACKAGE_NAME, + version: '0.3.0-dev.1', + title: 'Elastic APM', + }, + }; +} + +function getApmPackageInputVars(apmServerSchema: Record) { + const apmServerConfigs = Object.entries( + apmConfigMapping + ).map(([key, { name, type }]) => ({ key, name, type })); + + const inputVars: Record< + string, + { type: string; value: any } + > = apmServerConfigs.reduce((acc, { key, name, type }) => { + const value = apmServerSchema[key] ?? ''; // defaults to an empty string to be edited in Fleet UI + return { + ...acc, + [name]: { type, value }, + }; + }, {}); + return inputVars; +} + +export const apmConfigMapping: Record< + string, + { name: string; type: string } +> = { + 'apm-server.host': { + name: 'host', + type: 'text', + }, + 'apm-server.url': { + name: 'url', + type: 'text', + }, + 'apm-server.secret_token': { + name: 'secret_token', + type: 'text', + }, + 'apm-server.api_key.enabled': { + name: 'api_key_enabled', + type: 'bool', + }, + 'apm-server.rum.enabled': { + name: 'enable_rum', + type: 'bool', + }, + 'apm-server.default_service_environment': { + name: 'default_service_environment', + type: 'text', + }, + 'apm-server.rum.allow_service_names': { + name: 'rum_allow_service_names', + type: 'text', + }, + 'apm-server.rum.allow_origins': { + name: 'rum_allow_origins', + type: 'text', + }, + 'apm-server.rum.allow_headers': { + name: 'rum_allow_headers', + type: 'text', + }, + 'apm-server.rum.response_headers': { + name: 'rum_response_headers', + type: 'yaml', + }, + 'apm-server.rum.event_rate.limit': { + name: 'rum_event_rate_limit', + type: 'integer', + }, + 'apm-server.rum.event_rate.lru_size': { + name: 'rum_event_rate_lru_size', + type: 'integer', + }, + 'apm-server.api_key.limit': { + name: 'api_key_limit', + type: 'integer', + }, + 'apm-server.max_event_size': { + name: 'max_event_bytes', + type: 'integer', + }, + 'apm-server.capture_personal_data': { + name: 'capture_personal_data', + type: 'bool', + }, + 'apm-server.max_header_size': { + name: 'max_header_bytes', + type: 'integer', + }, + 'apm-server.idle_timeout': { + name: 'idle_timeout', + type: 'text', + }, + 'apm-server.read_timeout': { + name: 'read_timeout', + type: 'text', + }, + 'apm-server.shutdown_timeout': { + name: 'shutdown_timeout', + type: 'text', + }, + 'apm-server.write_timeout': { + name: 'write_timeout', + type: 'text', + }, + 'apm-server.max_connections': { + name: 'max_connections', + type: 'integer', + }, + 'apm-server.response_headers': { + name: 'response_headers', + type: 'yaml', + }, + 'apm-server.expvar.enabled': { + name: 'expvar_enabled', + type: 'bool', + }, + 'apm-server.ssl.enabled': { + name: 'tls_enabled', + type: 'bool', + }, + 'apm-server.ssl.certificate': { + name: 'tls_certificate', + type: 'text', + }, + 'apm-server.ssl.key': { + name: 'tls_key', + type: 'text', + }, + 'apm-server.ssl.supported_protocols': { + name: 'tls_supported_protocols', + type: 'text', + }, + 'apm-server.ssl.cipher_suites': { + name: 'tls_cipher_suites', + type: 'text', + }, + 'apm-server.ssl.curve_types': { + name: 'tls_curve_types', + type: 'text', + }, +}; diff --git a/x-pack/plugins/apm/server/lib/fleet/get_cloud_apm_package_policy.ts b/x-pack/plugins/apm/server/lib/fleet/get_cloud_apm_package_policy.ts new file mode 100644 index 0000000000000..b0044701e4020 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/fleet/get_cloud_apm_package_policy.ts @@ -0,0 +1,44 @@ +/* + * 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 'kibana/server'; +import { Maybe } from '../../../typings/common'; +import { AgentPolicy, PackagePolicy } from '../../../../fleet/common'; +import { APMPluginStartDependencies } from '../../types'; + +export const POLICY_ELASTIC_AGENT_ON_CLOUD = 'policy-elastic-agent-on-cloud'; +export const APM_PACKAGE_NAME = 'apm'; + +export async function getCloudAgentPolicy({ + fleetPluginStart, + savedObjectsClient, +}: { + fleetPluginStart: NonNullable; + savedObjectsClient: SavedObjectsClientContract; +}) { + try { + return await fleetPluginStart.agentPolicyService.get( + savedObjectsClient, + POLICY_ELASTIC_AGENT_ON_CLOUD + ); + } catch (error) { + if (error?.output.statusCode === 404) { + return; + } + throw error; + } +} + +export function getApmPackagePolicy(agentPolicy: Maybe) { + if (!agentPolicy) { + return; + } + const packagePolicies = agentPolicy.package_policies as PackagePolicy[]; + return packagePolicies.find( + (packagePolicy) => packagePolicy?.package?.name === APM_PACKAGE_NAME + ); +} diff --git a/x-pack/plugins/apm/server/lib/fleet/get_unsupported_apm_server_schema.ts b/x-pack/plugins/apm/server/lib/fleet/get_unsupported_apm_server_schema.ts new file mode 100644 index 0000000000000..5fec3c94cf7ac --- /dev/null +++ b/x-pack/plugins/apm/server/lib/fleet/get_unsupported_apm_server_schema.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 { SavedObjectsClientContract } from 'kibana/server'; +import { + APM_SERVER_SCHEMA_SAVED_OBJECT_TYPE, + APM_SERVER_SCHEMA_SAVED_OBJECT_ID, +} from '../../../common/apm_saved_object_constants'; +import { apmConfigMapping } from './get_apm_package_policy_definition'; + +export async function getUnsupportedApmServerSchema({ + savedObjectsClient, +}: { + savedObjectsClient: SavedObjectsClientContract; +}) { + const { attributes } = await savedObjectsClient.get( + APM_SERVER_SCHEMA_SAVED_OBJECT_TYPE, + APM_SERVER_SCHEMA_SAVED_OBJECT_ID + ); + const apmServerSchema: Record = JSON.parse( + (attributes as { schemaJson: string }).schemaJson + ); + return Object.entries(apmServerSchema) + .filter(([name]) => !(name in apmConfigMapping)) + .map(([key, value]) => ({ key, value })); +} diff --git a/x-pack/plugins/apm/server/lib/fleet/is_superuser.ts b/x-pack/plugins/apm/server/lib/fleet/is_superuser.ts new file mode 100644 index 0000000000000..1e4e596ab76e7 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/fleet/is_superuser.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { KibanaRequest } from 'kibana/server'; +import { APMPluginStartDependencies } from '../../types'; + +export function isSuperuser({ + securityPluginStart, + request, +}: { + securityPluginStart: NonNullable; + request: KibanaRequest; +}) { + const user = securityPluginStart.authc.getCurrentUser(request); + return user?.roles.includes('superuser'); +} diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index c9391eba29f8d..dd422e51550a2 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -31,7 +31,7 @@ import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_ import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; -import { apmIndices, apmTelemetry } from './saved_objects'; +import { apmIndices, apmTelemetry, apmServerSettings } from './saved_objects'; import { uiSettings } from './ui_settings'; import type { ApmPluginRequestHandlerContext, @@ -78,6 +78,7 @@ export class APMPlugin core.savedObjects.registerType(apmIndices); core.savedObjects.registerType(apmTelemetry); + core.savedObjects.registerType(apmServerSettings); core.uiSettings.register(uiSettings); diff --git a/x-pack/plugins/apm/server/routes/fleet.ts b/x-pack/plugins/apm/server/routes/fleet.ts index 01323add276df..7d2080c894ec6 100644 --- a/x-pack/plugins/apm/server/routes/fleet.ts +++ b/x-pack/plugins/apm/server/routes/fleet.ts @@ -7,18 +7,24 @@ import { keyBy } from 'lodash'; import Boom from '@hapi/boom'; +import * as t from 'io-ts'; import { i18n } from '@kbn/i18n'; +import { + APM_SERVER_SCHEMA_SAVED_OBJECT_TYPE, + APM_SERVER_SCHEMA_SAVED_OBJECT_ID, +} from '../../common/apm_saved_object_constants'; import { getFleetAgents } from '../lib/fleet/get_agents'; import { getApmPackgePolicies } from '../lib/fleet/get_apm_package_policies'; import { createApmServerRoute } from './create_apm_server_route'; import { createApmServerRouteRepository } from './create_apm_server_route_repository'; - -const FLEET_REQUIRED_MESSAGE = i18n.translate( - 'xpack.apm.fleet_has_data.fleetRequired', - { - defaultMessage: `Fleet plugin is required`, - } -); +import { + getCloudAgentPolicy, + getApmPackagePolicy, +} from '../lib/fleet/get_cloud_apm_package_policy'; +import { createCloudApmPackgePolicy } from '../lib/fleet/create_cloud_apm_package_policy'; +import { getUnsupportedApmServerSchema } from '../lib/fleet/get_unsupported_apm_server_schema'; +import { isSuperuser } from '../lib/fleet/is_superuser'; +import { getInternalSavedObjectsClient } from '../lib/helpers/get_internal_saved_objects_client'; const hasFleetDataRoute = createApmServerRoute({ endpoint: 'GET /api/apm/fleet/has_data', @@ -84,6 +90,119 @@ const fleetAgentsRoute = createApmServerRoute({ }, }); -export const ApmFleetRouteRepository = createApmServerRouteRepository() +const saveApmServerSchemaRoute = createApmServerRoute({ + endpoint: 'POST /api/apm/fleet/apm_server_schema', + options: { tags: ['access:apm', 'access:apm_write'] }, + params: t.type({ + body: t.type({ + schema: t.record(t.string, t.unknown), + }), + }), + handler: async (resources) => { + const { params, logger, core } = resources; + const savedObjectsClient = await getInternalSavedObjectsClient(core.setup); + const { schema } = params.body; + await savedObjectsClient.create( + APM_SERVER_SCHEMA_SAVED_OBJECT_TYPE, + { schemaJson: JSON.stringify(schema) }, + { id: APM_SERVER_SCHEMA_SAVED_OBJECT_ID, overwrite: true } + ); + logger.info(`Stored apm-server schema.`); + }, +}); + +const getUnsupportedApmServerSchemaRoute = createApmServerRoute({ + endpoint: 'GET /api/apm/fleet/apm_server_schema/unsupported', + options: { tags: ['access:apm'] }, + handler: async (resources) => { + const { context } = resources; + const savedObjectsClient = context.core.savedObjects.client; + return { + unsupported: await getUnsupportedApmServerSchema({ savedObjectsClient }), + }; + }, +}); + +const getMigrationCheckRoute = createApmServerRoute({ + endpoint: 'GET /api/apm/fleet/migration_check', + options: { tags: ['access:apm'] }, + handler: async (resources) => { + const { plugins, context, config, request } = resources; + const cloudApmMigrationEnabled = + config['xpack.apm.agent.migrations.enabled']; + if (!plugins.fleet || !plugins.security) { + throw Boom.internal(FLEET_SECURITY_REQUIRED_MESSAGE); + } + const savedObjectsClient = context.core.savedObjects.client; + const fleetPluginStart = await plugins.fleet.start(); + const securityPluginStart = await plugins.security.start(); + const hasRequiredRole = isSuperuser({ securityPluginStart, request }); + const cloudAgentPolicy = await getCloudAgentPolicy({ + savedObjectsClient, + fleetPluginStart, + }); + return { + has_cloud_agent_policy: !!cloudAgentPolicy, + has_cloud_apm_package_policy: !!getApmPackagePolicy(cloudAgentPolicy), + cloud_apm_migration_enabled: cloudApmMigrationEnabled, + has_required_role: hasRequiredRole, + }; + }, +}); + +const createCloudApmPackagePolicyRoute = createApmServerRoute({ + endpoint: 'POST /api/apm/fleet/cloud_apm_package_policy', + options: { tags: ['access:apm', 'access:apm_write'] }, + handler: async (resources) => { + const { plugins, context, config, request, logger } = resources; + const cloudApmMigrationEnabled = + config['xpack.apm.agent.migrations.enabled']; + if (!plugins.fleet || !plugins.security) { + throw Boom.internal(FLEET_SECURITY_REQUIRED_MESSAGE); + } + const savedObjectsClient = context.core.savedObjects.client; + const coreStart = await resources.core.start(); + const esClient = coreStart.elasticsearch.client.asScoped(resources.request) + .asCurrentUser; + const fleetPluginStart = await plugins.fleet.start(); + const securityPluginStart = await plugins.security.start(); + const hasRequiredRole = isSuperuser({ securityPluginStart, request }); + if (!hasRequiredRole || !cloudApmMigrationEnabled) { + throw Boom.forbidden(CLOUD_SUPERUSER_REQUIRED_MESSAGE); + } + return { + cloud_apm_package_policy: await createCloudApmPackgePolicy({ + fleetPluginStart, + savedObjectsClient, + esClient, + logger, + }), + }; + }, +}); + +export const apmFleetRouteRepository = createApmServerRouteRepository() .add(hasFleetDataRoute) - .add(fleetAgentsRoute); + .add(fleetAgentsRoute) + .add(saveApmServerSchemaRoute) + .add(getUnsupportedApmServerSchemaRoute) + .add(getMigrationCheckRoute) + .add(createCloudApmPackagePolicyRoute); + +const FLEET_REQUIRED_MESSAGE = i18n.translate( + 'xpack.apm.fleet_has_data.fleetRequired', + { defaultMessage: `Fleet plugin is required` } +); + +const FLEET_SECURITY_REQUIRED_MESSAGE = i18n.translate( + 'xpack.apm.api.fleet.fleetSecurityRequired', + { defaultMessage: `Fleet and Security plugins are required` } +); + +const CLOUD_SUPERUSER_REQUIRED_MESSAGE = i18n.translate( + 'xpack.apm.api.fleet.cloud_apm_package_policy.requiredRoleOnCloud', + { + defaultMessage: + 'Operation only permitted by Elastic Cloud users with the superuser role.', + } +); diff --git a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts index fa2f80f073958..4a277e2a42336 100644 --- a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts +++ b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts @@ -15,6 +15,7 @@ import { correlationsRouteRepository } from './correlations'; import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { environmentsRouteRepository } from './environments'; import { errorsRouteRepository } from './errors'; +import { apmFleetRouteRepository } from './fleet'; import { indexPatternRouteRepository } from './index_pattern'; import { metricsRouteRepository } from './metrics'; import { observabilityOverviewRouteRepository } from './observability_overview'; @@ -30,7 +31,6 @@ import { sourceMapsRouteRepository } from './source_maps'; import { traceRouteRepository } from './traces'; import { transactionRouteRepository } from './transactions'; import { APMRouteHandlerResources } from './typings'; -import { ApmFleetRouteRepository } from './fleet'; const getTypedGlobalApmServerRouteRepository = () => { const repository = createApmServerRouteRepository() @@ -52,7 +52,7 @@ const getTypedGlobalApmServerRouteRepository = () => { .merge(apmIndicesRouteRepository) .merge(customLinkRouteRepository) .merge(sourceMapsRouteRepository) - .merge(ApmFleetRouteRepository); + .merge(apmFleetRouteRepository); return repository; }; diff --git a/x-pack/plugins/apm/server/saved_objects/apm_server_settings.ts b/x-pack/plugins/apm/server/saved_objects/apm_server_settings.ts new file mode 100644 index 0000000000000..84c52e85c4397 --- /dev/null +++ b/x-pack/plugins/apm/server/saved_objects/apm_server_settings.ts @@ -0,0 +1,32 @@ +/* + * 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 { SavedObjectsType } from 'src/core/server'; +import { i18n } from '@kbn/i18n'; +import { APM_SERVER_SCHEMA_SAVED_OBJECT_TYPE } from '../../common/apm_saved_object_constants'; + +export const apmServerSettings: SavedObjectsType = { + name: APM_SERVER_SCHEMA_SAVED_OBJECT_TYPE, + hidden: false, + namespaceType: 'agnostic', + mappings: { + properties: { + schemaJson: { + type: 'text', + index: false, + }, + }, + }, + management: { + importableAndExportable: false, + icon: 'apmApp', + getTitle: () => + i18n.translate('xpack.apm.apmSchema.index', { + defaultMessage: 'APM Server Schema - Index', + }), + }, +}; diff --git a/x-pack/plugins/apm/server/saved_objects/index.ts b/x-pack/plugins/apm/server/saved_objects/index.ts index 370137af3dd44..ba4285a238968 100644 --- a/x-pack/plugins/apm/server/saved_objects/index.ts +++ b/x-pack/plugins/apm/server/saved_objects/index.ts @@ -7,3 +7,4 @@ export { apmIndices } from './apm_indices'; export { apmTelemetry } from './apm_telemetry'; +export { apmServerSettings } from './apm_server_settings';