[APM] Migrate to data streams - Fleet on Cloud (#102682)

* [APM] Adds migration to fleet-managed APM server in APM UI Settings (#100657)

* adds useStrictParams option to apm server routes to allow unknown record type in param.bopy

* Adds checks for required roles, policies, and config before allowing user to initiate migration

* refactored and cleaned up server-side code

* i18n and link to Fleet

* fixes linting issues and unit tests

* updates the apm package policy to 0.3.0 and adds some missing config mappings

* PR feedback

* Handles case where the cloud policy doesn't exist

* Reverts the addition of the useStrictParams option since strictKeysRt now supports records

* fixes default input var values and uses correct published package version

* displays reasons the switch to data streams is disabled

* Store apm-server schema with the internal saved objects client
This commit is contained in:
Oliver Gupte 2021-06-29 12:03:54 -04:00 committed by GitHub
parent 06eb881781
commit bfd801078f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1179 additions and 17 deletions

View file

@ -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';

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View file

@ -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 (
<EuiConfirmModal
title={i18n.translate('xpack.apm.settings.schema.confirm.title', {
defaultMessage: 'Please confirm your choice',
})}
cancelButtonText={i18n.translate(
'xpack.apm.settings.schema.confirm.cancelText',
{
defaultMessage: 'Cancel',
}
)}
onCancel={onCancel}
confirmButtonText={i18n.translate(
'xpack.apm.settings.schema.confirm.switchButtonText',
{
defaultMessage: 'Switch to data streams',
}
)}
defaultFocusedButton="confirm"
onConfirm={onConfirm}
confirmButtonDisabled={!isConfirmChecked}
isLoading={isLoading}
>
<p>
{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.',
})}
</p>
{!hasUnsupportedConfigs && (
<p>
{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.`,
}
)}
</p>
)}
<EuiCallOut
title={i18n.translate(
'xpack.apm.settings.schema.confirm.irreversibleWarning.title',
{
defaultMessage: `Switching to data streams is an irreversible action`,
}
)}
color="warning"
iconType="help"
>
<p>
{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.`,
}
)}
</p>
</EuiCallOut>
<EuiSpacer size="m" />
{hasUnsupportedConfigs && (
<>
<EuiCallOut
title={i18n.translate(
'xpack.apm.settings.schema.confirm.unsupportedConfigs.title',
{
defaultMessage: `The following apm-server.yml user settings are incompatible and will be removed`,
}
)}
iconType="iInCircle"
>
<EuiCodeBlock language="yaml">
{unsupportedConfigs
.map(({ key, value }) => `${key}: ${JSON.stringify(value)}`)
.join('\n')}
</EuiCodeBlock>
<p>
<ElasticDocsLink
section="/cloud"
path="/ec-manage-apm-settings.html"
target="_blank"
>
{i18n.translate(
'xpack.apm.settings.schema.confirm.apmServerSettingsCloudLinkText',
{ defaultMessage: 'Go to APM Server settings in Cloud' }
)}
</ElasticDocsLink>
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</>
)}
<p>
<EuiCheckbox
id={htmlIdGenerator()()}
label={i18n.translate(
'xpack.apm.settings.schema.confirm.checkboxLabel',
{
defaultMessage: `I confirm that I wish to switch to data streams`,
}
)}
checked={isConfirmChecked}
onChange={(e) => {
setIsConfirmChecked(e.target.checked);
}}
disabled={isLoading}
/>
</p>
</EuiConfirmModal>
);
}

View file

@ -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 (
<>
<SchemaOverview
onSwitch={async () => {
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 && (
<ConfirmSwitchModal
isLoading={isLoadingMigration}
onConfirm={async () => {
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,
});
}
}

View file

@ -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 (
<>
<SchemaOverviewHeading />
<EuiFlexGroup justifyContent="center">
<EuiLoadingSpinner size="xl" />
</EuiFlexGroup>
</>
);
}
if (isMigrated) {
return (
<>
<SchemaOverviewHeading />
<EuiFlexGroup justifyContent="center">
<EuiFlexItem />
<EuiFlexItem grow={2}>
<EuiCard
icon={
<EuiIcon
size="xxl"
type="checkInCircleFilled"
color="success"
/>
}
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={
<div>
<EuiButton href={fleetCloudAgentPolicyHref}>
{i18n.translate(
'xpack.apm.settings.schema.success.viewIntegrationInFleet.buttonText',
{ defaultMessage: 'View the APM integration in Fleet' }
)}
</EuiButton>
<EuiSpacer size="xs" />
<EuiText size="s">
<p>
<FormattedMessage
id="xpack.apm.settings.schema.success.returnText"
defaultMessage="or simply return to the {serviceInventoryLink}."
values={{
serviceInventoryLink: (
<APMLink path="/services">
{i18n.translate(
'xpack.apm.settings.schema.success.returnText.serviceInventoryLink',
{ defaultMessage: 'Service inventory' }
)}
</APMLink>
),
}}
/>
</p>
</EuiText>
</div>
}
/>
</EuiFlexItem>
<EuiFlexItem />
</EuiFlexGroup>
</>
);
}
return (
<>
<SchemaOverviewHeading />
<EuiFlexGroup justifyContent="center">
<EuiFlexItem />
<EuiFlexItem>
<EuiCard
icon={<EuiIcon size="xxl" type="documents" />}
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={
<div>
<EuiText size="s" color="subdued">
<p>
{i18n.translate(
'xpack.apm.settings.schema.migrate.classicIndices.currentSetup',
{ defaultMessage: 'Current setup' }
)}
</p>
</EuiText>
</div>
}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiCard
betaBadgeLabel={i18n.translate(
'xpack.apm.settings.schema.migrate.dataStreams.betaBadge.label',
{ defaultMessage: 'Beta' }
)}
betaBadgeTitle={i18n.translate(
'xpack.apm.settings.schema.migrate.dataStreams.betaBadge.title',
{ defaultMessage: 'Data streams' }
)}
betaBadgeTooltipContent={i18n.translate(
'xpack.apm.settings.schema.migrate.dataStreams.betaBadge.description',
{
defaultMessage:
'The switch to data streams is not GA. Please help us by reporting any bugs.',
}
)}
image={
<div>
<img src={rocketLaunchGraphic} alt="rocket launch" />
</div>
}
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={
<div>
<EuiToolTip
position="bottom"
content={getDisabledReason({
cloudApmMigrationEnabled,
hasCloudAgentPolicy,
hasRequiredRole,
})}
>
<EuiButton
fill
isLoading={isLoadingConfirmation}
isDisabled={isDisabled}
>
{i18n.translate(
'xpack.apm.settings.schema.migrate.dataStreams.buttonText',
{ defaultMessage: 'Switch to data streams' }
)}
</EuiButton>
</EuiToolTip>
</div>
}
onClick={onSwitch}
isDisabled={isDisabled}
/>
</EuiFlexItem>
<EuiFlexItem />
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiFlexGroup justifyContent="center" gutterSize="s">
<EuiFlexItem />
<EuiFlexItem grow={2}>
<EuiCallOut
title={i18n.translate(
'xpack.apm.settings.schema.migrate.calloutNote.title',
{ defaultMessage: 'Please note before switching' }
)}
iconType="iInCircle"
>
<p>
{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.',
}
)}
</p>
</EuiCallOut>
</EuiFlexItem>
<EuiFlexItem />
</EuiFlexGroup>
</>
);
}
export function SchemaOverviewHeading() {
return (
<>
<EuiText color="subdued">
<FormattedMessage
id="xpack.apm.settings.schema.descriptionText"
defaultMessage="We have created a simple and seamless process for switching from the classic APM indices to immediately take advantage of the new data streams features. Beware this action is {irreversibleEmphasis} and can only be performed by a {superuserEmphasis} with access to Fleet. Learn more about {dataStreamsDocLink}."
values={{
irreversibleEmphasis: (
<strong>
{i18n.translate(
'xpack.apm.settings.schema.descriptionText.irreversibleEmphasisText',
{ defaultMessage: 'irreversible' }
)}
</strong>
),
superuserEmphasis: (
<strong>
{i18n.translate(
'xpack.apm.settings.schema.descriptionText.superuserEmphasisText',
{ defaultMessage: 'superuser' }
)}
</strong>
),
dataStreamsDocLink: (
<ElasticDocsLink
section="/elasticsearch/reference"
path="/data-streams.html"
target="_blank"
>
{i18n.translate(
'xpack.apm.settings.schema.descriptionText.dataStreamsDocLinkText',
{ defaultMessage: 'data streams' }
)}
</ElasticDocsLink>
),
}}
/>
</EuiText>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="s">
<h2>
{i18n.translate('xpack.apm.settings.schema.title', {
defaultMessage: 'Schema',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
</>
);
}
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 (
<FormattedMessage
id="xpack.apm.settings.schema.disabledReason"
defaultMessage="Switch to data streams is unavailable: {reasons}"
values={{
reasons: (
<ul>
{reasons.map((reasonText, index) => (
<li key={index}>- {reasonText}</li>
))}
</ul>
),
}}
/>
);
}
}

View file

@ -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 (
<SettingsTemplate selectedTab="schema">
<Schema />
</SettingsTemplate>
);
}
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',

View file

@ -20,7 +20,8 @@ type Tab = NonNullable<EuiPageHeaderProps['tabs']>[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

View file

@ -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' ? (

View file

@ -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');
}

View file

@ -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-*',

View file

@ -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) {

View file

@ -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<APMPluginStartDependencies['fleet']>;
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<string, any> = 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;
}

View file

@ -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<string, any>
) {
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<string, any>) {
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',
},
};

View file

@ -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<APMPluginStartDependencies['fleet']>;
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<AgentPolicy>) {
if (!agentPolicy) {
return;
}
const packagePolicies = agentPolicy.package_policies as PackagePolicy[];
return packagePolicies.find(
(packagePolicy) => packagePolicy?.package?.name === APM_PACKAGE_NAME
);
}

View file

@ -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<string, any> = JSON.parse(
(attributes as { schemaJson: string }).schemaJson
);
return Object.entries(apmServerSchema)
.filter(([name]) => !(name in apmConfigMapping))
.map(([key, value]) => ({ key, value }));
}

View file

@ -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<APMPluginStartDependencies['security']>;
request: KibanaRequest;
}) {
const user = securityPluginStart.authc.getCurrentUser(request);
return user?.roles.includes('superuser');
}

View file

@ -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);

View file

@ -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.',
}
);

View file

@ -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;
};

View file

@ -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',
}),
},
};

View file

@ -7,3 +7,4 @@
export { apmIndices } from './apm_indices';
export { apmTelemetry } from './apm_telemetry';
export { apmServerSettings } from './apm_server_settings';