[Fleet] Fleet server onboarding UI (#96867)

This commit is contained in:
Nicolas Chaulet 2021-04-15 01:22:32 -04:00 committed by GitHub
parent 1cd11d4deb
commit b96172e27c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 693 additions and 594 deletions

View file

@ -27,6 +27,8 @@ export const FLEET_SERVER_INDICES_VERSION = 1;
export const FLEET_SERVER_ARTIFACTS_INDEX = '.fleet-artifacts';
export const FLEET_SERVER_SERVERS_INDEX = '.fleet-servers';
export const FLEET_SERVER_INDICES = [
'.fleet-actions',
'.fleet-agents',
@ -34,5 +36,5 @@ export const FLEET_SERVER_INDICES = [
'.fleet-enrollment-api-keys',
'.fleet-policies',
'.fleet-policies-leader',
'.fleet-servers',
FLEET_SERVER_SERVERS_INDEX,
];

View file

@ -15,6 +15,7 @@ export interface GetFleetStatusResponse {
| 'tls_required'
| 'api_keys'
| 'fleet_admin_user'
| 'fleet_server'
| 'encrypted_saved_object_encryption_key_required'
>;
}

View file

@ -14,9 +14,7 @@ import type { EnrollmentAPIKey } from '../../../types';
interface Props {
fleetServerHosts: string[];
kibanaUrl: string;
apiKey: EnrollmentAPIKey;
kibanaCASha256?: string;
}
// Otherwise the copy button is over the text
@ -28,28 +26,11 @@ function getfleetServerHostsEnrollArgs(apiKey: EnrollmentAPIKey, fleetServerHost
return `--url=${fleetServerHosts[0]} --enrollment-token=${apiKey.api_key}`;
}
function getKibanaUrlEnrollArgs(
apiKey: EnrollmentAPIKey,
kibanaUrl: string,
kibanaCASha256?: string
) {
return `--kibana-url=${kibanaUrl} --enrollment-token=${apiKey.api_key}${
kibanaCASha256 ? ` --ca_sha256=${kibanaCASha256}` : ''
}`;
}
export const ManualInstructions: React.FunctionComponent<Props> = ({
kibanaUrl,
apiKey,
kibanaCASha256,
fleetServerHosts,
}) => {
const fleetServerHostsNotEmpty = fleetServerHosts.length > 0;
const enrollArgs = fleetServerHostsNotEmpty
? getfleetServerHostsEnrollArgs(apiKey, fleetServerHosts)
: // TODO remove as part of https://github.com/elastic/kibana/issues/94303
getKibanaUrlEnrollArgs(apiKey, kibanaUrl, kibanaCASha256);
const enrollArgs = getfleetServerHostsEnrollArgs(apiKey, fleetServerHosts);
const linuxMacCommand = `./elastic-agent install -f ${enrollArgs}`;

View file

@ -55,38 +55,15 @@ function isSameArrayValue(arrayA: string[] = [], arrayB: string[] = []) {
function useSettingsForm(outputId: string | undefined, onSuccess: () => void) {
const [isLoading, setIsloading] = React.useState(false);
const { notifications } = useStartServices();
const kibanaUrlsInput = useComboInput([], (value) => {
const fleetServerHostsInput = useComboInput([], (value) => {
if (value.length === 0) {
return [
i18n.translate('xpack.fleet.settings.kibanaUrlEmptyError', {
i18n.translate('xpack.fleet.settings.fleetServerHostsEmptyError', {
defaultMessage: 'At least one URL is required',
}),
];
}
if (value.some((v) => !v.match(URL_REGEX))) {
return [
i18n.translate('xpack.fleet.settings.kibanaUrlError', {
defaultMessage: 'Invalid URL',
}),
];
}
if (isDiffPathProtocol(value)) {
return [
i18n.translate('xpack.fleet.settings.kibanaUrlDifferentPathOrProtocolError', {
defaultMessage: 'Protocol and path must be the same for each URL',
}),
];
}
});
const fleetServerHostsInput = useComboInput([], (value) => {
// TODO enable as part of https://github.com/elastic/kibana/issues/94303
// if (value.length === 0) {
// return [
// i18n.translate('xpack.fleet.settings.fleetServerHostsEmptyError', {
// defaultMessage: 'At least one URL is required',
// }),
// ];
// }
if (value.some((v) => !v.match(URL_REGEX))) {
return [
i18n.translate('xpack.fleet.settings.fleetServerHostsError', {
@ -129,7 +106,6 @@ function useSettingsForm(outputId: string | undefined, onSuccess: () => void) {
const validate = useCallback(() => {
if (
!kibanaUrlsInput.validate() ||
!fleetServerHostsInput.validate() ||
!elasticsearchUrlInput.validate() ||
!additionalYamlConfigInput.validate()
@ -138,7 +114,7 @@ function useSettingsForm(outputId: string | undefined, onSuccess: () => void) {
}
return true;
}, [kibanaUrlsInput, fleetServerHostsInput, elasticsearchUrlInput, additionalYamlConfigInput]);
}, [fleetServerHostsInput, elasticsearchUrlInput, additionalYamlConfigInput]);
return {
isLoading,
@ -157,7 +133,6 @@ function useSettingsForm(outputId: string | undefined, onSuccess: () => void) {
throw outputResponse.error;
}
const settingsResponse = await sendPutSettings({
kibana_urls: kibanaUrlsInput.value,
fleet_server_hosts: fleetServerHostsInput.value,
});
if (settingsResponse.error) {
@ -179,7 +154,6 @@ function useSettingsForm(outputId: string | undefined, onSuccess: () => void) {
},
inputs: {
fleetServerHosts: fleetServerHostsInput,
kibanaUrls: kibanaUrlsInput,
elasticsearchUrl: elasticsearchUrlInput,
additionalYamlConfig: additionalYamlConfigInput,
},
@ -220,7 +194,6 @@ export const SettingFlyout: React.FunctionComponent<Props> = ({ onClose }) => {
useEffect(() => {
if (settings) {
inputs.kibanaUrls.setValue([...settings.kibana_urls]);
inputs.fleetServerHosts.setValue([...settings.fleet_server_hosts]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -231,7 +204,6 @@ export const SettingFlyout: React.FunctionComponent<Props> = ({ onClose }) => {
return false;
}
return (
!isSameArrayValue(settings.kibana_urls, inputs.kibanaUrls.value) ||
!isSameArrayValue(settings.fleet_server_hosts, inputs.fleetServerHosts.value) ||
!isSameArrayValue(output.hosts, inputs.elasticsearchUrl.value) ||
(output.config_yaml || '') !== inputs.additionalYamlConfig.value
@ -329,17 +301,6 @@ export const SettingFlyout: React.FunctionComponent<Props> = ({ onClose }) => {
<EuiComboBox fullWidth noSuggestions {...inputs.fleetServerHosts.props} />
</EuiFormRow>
<EuiSpacer size="m" />
{/* // TODO remove as part of https://github.com/elastic/kibana/issues/94303 */}
<EuiFormRow
fullWidth
label={i18n.translate('xpack.fleet.settings.kibanaUrlLabel', {
defaultMessage: 'Kibana hosts',
})}
{...inputs.kibanaUrls.formRowProps}
>
<EuiComboBox fullWidth noSuggestions {...inputs.kibanaUrls.props} />
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow
fullWidth

View file

@ -15,6 +15,7 @@ export {
AGENT_SAVED_OBJECT_TYPE,
ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE,
PACKAGE_POLICY_SAVED_OBJECT_TYPE,
FLEET_SERVER_PACKAGE,
// Fleet Server index
AGENTS_INDEX,
ENROLLMENT_API_KEYS_INDEX,

View file

@ -18,6 +18,7 @@ export { usePagination, Pagination, PAGE_SIZE_OPTIONS } from './use_pagination';
export { useUrlPagination } from './use_url_pagination';
export { useSorting } from './use_sorting';
export { useDebounce } from './use_debounce';
export { useUrlModal } from './use_url_modal';
export * from './use_request';
export * from './use_input';
export * from './use_url_params';

View file

@ -0,0 +1,67 @@
/*
* 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 { useCallback, useMemo } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useUrlParams } from './use_url_params';
type Modal = 'settings';
/**
* Uses URL params for pagination and also persists those to the URL as they are updated
*/
export const useUrlModal = () => {
const location = useLocation();
const history = useHistory();
const { urlParams, toUrlParams } = useUrlParams();
const setModal = useCallback(
(modal: Modal | null) => {
const newUrlParams: any = {
...urlParams,
modal,
};
if (modal === null) {
delete newUrlParams.modal;
}
history.push({
...location,
search: toUrlParams(newUrlParams),
});
},
[history, location, toUrlParams, urlParams]
);
const getModalHref = useCallback(
(modal: Modal | null) => {
return history.createHref({
...location,
search: toUrlParams({
...urlParams,
modal,
}),
});
},
[history, location, toUrlParams, urlParams]
);
const modal: Modal | null = useMemo(() => {
if (urlParams.modal === 'settings') {
return urlParams.modal;
}
return null;
}, [urlParams.modal]);
return {
modal,
setModal,
getModalHref,
};
};

View file

@ -12,7 +12,7 @@ import { FormattedMessage } from '@kbn/i18n/react';
import type { Section } from '../sections';
import { AlphaMessaging, SettingFlyout } from '../components';
import { useLink, useConfig } from '../hooks';
import { useLink, useConfig, useUrlModal } from '../hooks';
interface Props {
showSettings?: boolean;
@ -53,17 +53,18 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({
}) => {
const { getHref } = useLink();
const { agents } = useConfig();
const [isSettingsFlyoutOpen, setIsSettingsFlyoutOpen] = React.useState(false);
const { modal, setModal, getModalHref } = useUrlModal();
return (
<>
{isSettingsFlyoutOpen && (
{modal === 'settings' && (
<SettingFlyout
onClose={() => {
setIsSettingsFlyoutOpen(false);
setModal(null);
}}
/>
)}
<Container>
<Wrapper>
<Nav>
@ -122,7 +123,7 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({
</EuiFlexItem>
{showSettings ? (
<EuiFlexItem>
<EuiButtonEmpty iconType="gear" onClick={() => setIsSettingsFlyoutOpen(true)}>
<EuiButtonEmpty iconType="gear" href={getModalHref('settings')}>
<FormattedMessage
id="xpack.fleet.appNavigation.settingsButton"
defaultMessage="Fleet settings"

View file

@ -0,0 +1,148 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiPageBody,
EuiPageContent,
EuiText,
EuiSpacer,
EuiIcon,
EuiCallOut,
EuiFlexItem,
EuiFlexGroup,
EuiCode,
EuiCodeBlock,
EuiLink,
} from '@elastic/eui';
import { WithoutHeaderLayout } from '../../../layouts';
import type { GetFleetStatusResponse } from '../../../types';
export const RequirementItem: React.FunctionComponent<{ isMissing: boolean }> = ({
isMissing,
children,
}) => {
return (
<EuiFlexGroup gutterSize="s" alignItems="flexStart">
<EuiFlexItem grow={false}>
<EuiText>
{isMissing ? (
<EuiIcon type="crossInACircleFilled" color="danger" />
) : (
<EuiIcon type="checkInCircleFilled" color="success" />
)}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>{children}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const MissingESRequirementsPage: React.FunctionComponent<{
missingRequirements: GetFleetStatusResponse['missing_requirements'];
}> = ({ missingRequirements }) => {
return (
<WithoutHeaderLayout>
<EuiPageBody restrictWidth={820}>
<EuiPageContent hasBorder={true} hasShadow={false}>
<EuiCallOut
title={i18n.translate('xpack.fleet.setupPage.missingRequirementsCalloutTitle', {
defaultMessage: 'Missing security requirements',
})}
color="warning"
iconType="alert"
>
<FormattedMessage
id="xpack.fleet.setupPage.missingRequirementsCalloutDescription"
defaultMessage="To use central management for Elastic Agents, enable the following Elasticsearch security features."
/>
</EuiCallOut>
<EuiSpacer size="m" />
<FormattedMessage
id="xpack.fleet.setupPage.missingRequirementsElasticsearchTitle"
defaultMessage="In your Elasticsearch policy, enable:"
/>
<EuiSpacer size="l" />
<RequirementItem isMissing={false}>
<FormattedMessage
id="xpack.fleet.setupPage.elasticsearchSecurityFlagText"
defaultMessage="{esSecurityLink}. Set {securityFlag} to {true} ."
values={{
esSecurityLink: (
<EuiLink
href="https://www.elastic.co/guide/en/elasticsearch/reference/current/configuring-security.html"
target="_blank"
external
>
<FormattedMessage
id="xpack.fleet.setupPage.elasticsearchSecurityLink"
defaultMessage="Elasticsearch security"
/>
</EuiLink>
),
securityFlag: <EuiCode>xpack.security.enabled</EuiCode>,
true: <EuiCode>true</EuiCode>,
}}
/>
</RequirementItem>
<EuiSpacer size="s" />
<RequirementItem isMissing={missingRequirements.includes('api_keys')}>
<FormattedMessage
id="xpack.fleet.setupPage.elasticsearchApiKeyFlagText"
defaultMessage="{apiKeyLink}. Set {apiKeyFlag} to {true} ."
values={{
apiKeyFlag: <EuiCode>xpack.security.authc.api_key.enabled</EuiCode>,
true: <EuiCode>true</EuiCode>,
apiKeyLink: (
<EuiLink
href="https://www.elastic.co/guide/en/elasticsearch/reference/current/security-settings.html#api-key-service-settings"
target="_blank"
external
>
<FormattedMessage
id="xpack.fleet.setupPage.apiKeyServiceLink"
defaultMessage="API key service"
/>
</EuiLink>
),
}}
/>
</RequirementItem>
<EuiSpacer size="m" />
<EuiCodeBlock isCopyable={true}>
{`xpack.security.enabled: true
xpack.security.authc.api_key.enabled: true`}
</EuiCodeBlock>
<EuiSpacer size="l" />
<FormattedMessage
id="xpack.fleet.setupPage.gettingStartedText"
defaultMessage="For more information, read our {link} guide."
values={{
link: (
<EuiLink
href="https://www.elastic.co/guide/en/fleet/current/index.html"
target="_blank"
external
>
<FormattedMessage
id="xpack.fleet.setupPage.gettingStartedLink"
defaultMessage="Getting Started"
/>
</EuiLink>
),
}}
/>
</EuiPageContent>
</EuiPageBody>
</WithoutHeaderLayout>
);
};

View file

@ -0,0 +1,159 @@
/*
* 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 {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiPanel,
EuiSpacer,
EuiText,
EuiLink,
EuiEmptyPrompt,
} from '@elastic/eui';
import styled from 'styled-components';
import { FormattedMessage } from 'react-intl';
import { useStartServices } from '../../../hooks';
export const ContentWrapper = styled(EuiFlexGroup)`
height: 100%;
`;
function renderOnPremInstructions() {
return (
<EuiPanel
paddingSize="none"
grow={false}
hasShadow={false}
hasBorder={true}
className="eui-textCenter"
>
<EuiEmptyPrompt
title={
<h2>
<FormattedMessage
id="xpack.fleet.fleetServerSetup.setupTitle"
defaultMessage="Add a Fleet Server"
/>
</h2>
}
body={
<FormattedMessage
id="xpack.fleet.fleetServerSetup.setupText"
defaultMessage="A Fleet Server is required before you can enroll agents with Fleet. See the Fleet User Guide for instructions on how to add a Fleet Server."
/>
}
actions={
<EuiButton
iconSide="right"
iconType="popout"
fill
isLoading={false}
type="submit"
href="https://ela.st/add-fleet-server"
target="_blank"
>
<FormattedMessage
id="xpack.fleet.fleetServerSetup.setupGuideLink"
defaultMessage="Fleet User Guide"
/>
</EuiButton>
}
/>
</EuiPanel>
);
}
function renderCloudInstructions(deploymentUrl: string) {
return (
<EuiPanel
paddingSize="none"
grow={false}
hasShadow={false}
hasBorder={true}
className="eui-textCenter"
>
<EuiEmptyPrompt
title={
<h2>
<FormattedMessage
id="xpack.fleet.fleetServerSetup.cloudSetupTitle"
defaultMessage="Enable APM & Fleet"
/>
</h2>
}
body={
<FormattedMessage
id="xpack.fleet.fleetServerSetup.cloudSetupText"
defaultMessage="A Fleet Server is required before you can enroll agents with Fleet. You can add one to your deployment by enabling APM & Fleet. For more information see the {link}"
values={{
link: (
<EuiLink href="https://ela.st/add-fleet-server" target="_blank" external>
<FormattedMessage
id="xpack.fleet.settings.userGuideLink"
defaultMessage="Fleet User Guide"
/>
</EuiLink>
),
}}
/>
}
actions={
<>
<EuiButton
iconSide="right"
iconType="popout"
fill
isLoading={false}
type="submit"
href={deploymentUrl}
target="_blank"
>
<FormattedMessage
id="xpack.fleet.fleetServerSetup.cloudDeploymentLink"
defaultMessage="Edit deployment"
/>
</EuiButton>
</>
}
/>
</EuiPanel>
);
}
export const FleetServerRequirementPage = () => {
const startService = useStartServices();
const deploymentUrl = startService.cloud?.deploymentUrl;
return (
<>
<ContentWrapper justifyContent="center" alignItems="center">
<EuiFlexItem grow={false}>
{deploymentUrl ? renderCloudInstructions(deploymentUrl) : renderOnPremInstructions()}
<EuiSpacer size="l" />
<EuiFlexGroup gutterSize="s" alignItems="center" justifyContent="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
<FormattedMessage
id="xpack.fleet.fleetServerSetup.waitingText"
defaultMessage="Waiting for a Fleet Server to connect..."
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</ContentWrapper>
<EuiSpacer size="xxl" />
</>
);
};

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { MissingESRequirementsPage } from './es_requirements_page';
export { FleetServerRequirementPage } from './fleet_server_requirement_page';

View file

@ -20,9 +20,13 @@ import {
EuiFlyoutFooter,
EuiTab,
EuiTabs,
EuiCallOut,
EuiLink,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { useGetSettings, useUrlModal } from '../../../../hooks';
import type { AgentPolicy } from '../../../../types';
import { ManagedInstructions } from './managed_instructions';
@ -33,12 +37,61 @@ interface Props {
agentPolicies?: AgentPolicy[];
}
const MissingFleetServerHostCallout: React.FunctionComponent<{ onClose: () => void }> = ({
onClose,
}) => {
const { setModal } = useUrlModal();
return (
<EuiCallOut
title={i18n.translate('xpack.fleet.agentEnrollment.missingFleetHostCalloutTitle', {
defaultMessage: 'Missing URL for Fleet Server host',
})}
>
<FormattedMessage
id="xpack.fleet.agentEnrollment.missingFleetHostCalloutText"
defaultMessage="A URL for your Fleet Server host is required to enroll agents with Fleet. You can add this information in Fleet Settings. For more information, see the {link}."
values={{
link: (
<EuiLink
href="https://www.elastic.co/guide/en/fleet/current/index.html"
target="_blank"
external
>
<FormattedMessage
id="xpack.fleet.agentEnrollment.missingFleetHostGuideLink"
defaultMessage="Fleet User Guide"
/>
</EuiLink>
),
}}
/>
<EuiSpacer size="m" />
<EuiButton
fill
iconType="gear"
onClick={() => {
onClose();
setModal('settings');
}}
>
<FormattedMessage
id="xpack.fleet.agentEnrollment.fleetSettingsLink"
defaultMessage="Fleet Settings"
/>
</EuiButton>
</EuiCallOut>
);
};
export const AgentEnrollmentFlyout: React.FunctionComponent<Props> = ({
onClose,
agentPolicies,
}) => {
const [mode, setMode] = useState<'managed' | 'standalone'>('managed');
const settings = useGetSettings();
const fleetServerHosts = settings.data?.item?.fleet_server_hosts || [];
return (
<EuiFlyout onClose={onClose} size="l" maxWidth={880}>
<EuiFlyoutHeader hasBorder aria-labelledby="FleetAgentEnrollmentFlyoutTitle">
@ -74,8 +127,14 @@ export const AgentEnrollmentFlyout: React.FunctionComponent<Props> = ({
</EuiTabs>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{mode === 'managed' ? (
<EuiFlyoutBody
banner={
fleetServerHosts.length === 0 ? (
<MissingFleetServerHostCallout onClose={onClose} />
) : undefined
}
>
{fleetServerHosts.length === 0 ? null : mode === 'managed' ? (
<ManagedInstructions agentPolicies={agentPolicies} />
) : (
<StandaloneInstructions agentPolicies={agentPolicies} />

View file

@ -14,12 +14,12 @@ import { FormattedMessage } from '@kbn/i18n/react';
import type { AgentPolicy } from '../../../../types';
import {
useGetOneEnrollmentAPIKey,
useStartServices,
useGetSettings,
useLink,
useFleetStatus,
} from '../../../../hooks';
import { ManualInstructions } from '../../../../components/enrollment_instructions';
import { FleetServerRequirementPage } from '../../agent_requirements_page';
import { DownloadStep, AgentPolicySelectionStep } from './steps';
@ -27,22 +27,41 @@ interface Props {
agentPolicies?: AgentPolicy[];
}
export const ManagedInstructions = React.memo<Props>(({ agentPolicies }) => {
const DefaultMissingRequirements = () => {
const { getHref } = useLink();
const core = useStartServices();
return (
<>
<FormattedMessage
id="xpack.fleet.agentEnrollment.agentsNotInitializedText"
defaultMessage="Before enrolling agents, {link}."
values={{
link: (
<EuiLink href={getHref('fleet')}>
<FormattedMessage
id="xpack.fleet.agentEnrollment.setUpAgentsLink"
defaultMessage="set up central management for Elastic Agents"
/>
</EuiLink>
),
}}
/>
</>
);
};
const FleetServerMissingRequirements = () => {
return <FleetServerRequirementPage />;
};
export const ManagedInstructions = React.memo<Props>(({ agentPolicies }) => {
const fleetStatus = useFleetStatus();
const [selectedAPIKeyId, setSelectedAPIKeyId] = useState<string | undefined>();
const settings = useGetSettings();
const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId);
const kibanaUrlsSettings = settings.data?.item?.kibana_urls;
const kibanaUrl = kibanaUrlsSettings
? kibanaUrlsSettings[0]
: `${window.location.origin}${core.http.basePath.get()}`;
const kibanaCASha256 = settings.data?.item?.kibana_ca_sha256;
const settings = useGetSettings();
const fleetServerHosts = settings.data?.item?.fleet_server_hosts || [];
const steps: EuiContainedStepProps[] = [
DownloadStep(),
@ -52,46 +71,29 @@ export const ManagedInstructions = React.memo<Props>(({ agentPolicies }) => {
defaultMessage: 'Enroll and start the Elastic Agent',
}),
children: apiKey.data && (
<ManualInstructions
apiKey={apiKey.data.item}
kibanaUrl={kibanaUrl}
kibanaCASha256={kibanaCASha256}
fleetServerHosts={settings.data?.item?.fleet_server_hosts || []}
/>
<ManualInstructions apiKey={apiKey.data.item} fleetServerHosts={fleetServerHosts} />
),
},
];
return (
<>
<EuiText>
<FormattedMessage
id="xpack.fleet.agentEnrollment.managedDescription"
defaultMessage="Enroll an Elastic Agent in Fleet to automatically deploy updates and centrally manage the agent."
/>
</EuiText>
<EuiSpacer size="l" />
{fleetStatus.isReady ? (
<>
<EuiText>
<FormattedMessage
id="xpack.fleet.agentEnrollment.managedDescription"
defaultMessage="Enroll an Elastic Agent in Fleet to automatically deploy updates and centrally manage the agent."
/>
</EuiText>
<EuiSpacer size="l" />
<EuiSteps steps={steps} />
</>
) : fleetStatus.missingRequirements?.length === 1 &&
fleetStatus.missingRequirements[0] === 'fleet_server' ? (
<FleetServerMissingRequirements />
) : (
<>
<FormattedMessage
id="xpack.fleet.agentEnrollment.agentsNotInitializedText"
defaultMessage="Before enrolling agents, {link}."
values={{
link: (
<EuiLink href={getHref('fleet')}>
<FormattedMessage
id="xpack.fleet.agentEnrollment.setUpAgentsLink"
defaultMessage="set up central management for Elastic Agents"
/>
</EuiLink>
),
}}
/>
</>
<DefaultMissingRequirements />
)}
</>
);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { HashRouter as Router, Route, Switch, Redirect } from 'react-router-dom';
@ -15,21 +15,43 @@ import { useConfig, useFleetStatus, useBreadcrumbs, useCapabilities } from '../.
import { WithoutHeaderLayout } from '../../layouts';
import { AgentListPage } from './agent_list_page';
import { SetupPage } from './setup_page';
import { FleetServerRequirementPage, MissingESRequirementsPage } from './agent_requirements_page';
import { AgentDetailsPage } from './agent_details_page';
import { NoAccessPage } from './error_pages/no_access';
import { EnrollmentTokenListPage } from './enrollment_token_list_page';
import { ListLayout } from './components/list_layout';
const REFRESH_INTERVAL_MS = 30000;
export const FleetApp: React.FunctionComponent = () => {
useBreadcrumbs('fleet');
const { agents } = useConfig();
const capabilities = useCapabilities();
const fleetStatus = useFleetStatus();
useEffect(() => {
if (
!agents.enabled ||
fleetStatus.isLoading ||
!fleetStatus.missingRequirements ||
!fleetStatus.missingRequirements.includes('fleet_server')
) {
return;
}
const interval = setInterval(() => {
fleetStatus.refresh();
}, REFRESH_INTERVAL_MS);
return () => {
clearInterval(interval);
};
}, [fleetStatus, agents.enabled]);
if (!agents.enabled) return null;
if (fleetStatus.isLoading) {
if (!fleetStatus.missingRequirements && fleetStatus.isLoading) {
return <Loading />;
}
@ -49,13 +71,16 @@ export const FleetApp: React.FunctionComponent = () => {
);
}
if (fleetStatus.isReady === false) {
return (
<SetupPage
missingRequirements={fleetStatus.missingRequirements || []}
refresh={fleetStatus.refresh}
/>
);
const hasOnlyFleetServerMissingRequirement =
fleetStatus?.missingRequirements?.length === 1 &&
fleetStatus.missingRequirements[0] === 'fleet_server';
if (
!hasOnlyFleetServerMissingRequirement &&
fleetStatus.missingRequirements &&
fleetStatus.missingRequirements.length > 0
) {
return <MissingESRequirementsPage missingRequirements={fleetStatus.missingRequirements} />;
}
if (!capabilities.read) {
return <NoAccessPage />;
@ -74,7 +99,11 @@ export const FleetApp: React.FunctionComponent = () => {
</Route>
<Route path={PAGE_ROUTING_PATHS.fleet_agent_list}>
<ListLayout>
<AgentListPage />
{hasOnlyFleetServerMissingRequirement ? (
<FleetServerRequirementPage />
) : (
<AgentListPage />
)}
</ListLayout>
</Route>
<Route path={PAGE_ROUTING_PATHS.fleet_enrollment_tokens}>

View file

@ -1,284 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiPageBody,
EuiPageContent,
EuiForm,
EuiText,
EuiButton,
EuiTitle,
EuiSpacer,
EuiIcon,
EuiCallOut,
EuiFlexItem,
EuiFlexGroup,
EuiCode,
EuiCodeBlock,
EuiLink,
} from '@elastic/eui';
import { useStartServices, sendPostFleetSetup } from '../../../hooks';
import { WithoutHeaderLayout } from '../../../layouts';
import type { GetFleetStatusResponse } from '../../../types';
export const RequirementItem: React.FunctionComponent<{ isMissing: boolean }> = ({
isMissing,
children,
}) => {
return (
<EuiFlexGroup gutterSize="s" alignItems="flexStart">
<EuiFlexItem grow={false}>
<EuiText>
{isMissing ? (
<EuiIcon type="crossInACircleFilled" color="danger" />
) : (
<EuiIcon type="checkInCircleFilled" color="success" />
)}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>{children}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const SetupPage: React.FunctionComponent<{
refresh: () => Promise<void>;
missingRequirements: GetFleetStatusResponse['missing_requirements'];
}> = ({ refresh, missingRequirements }) => {
const [isFormLoading, setIsFormLoading] = useState<boolean>(false);
const core = useStartServices();
const onSubmit = async () => {
setIsFormLoading(true);
try {
await sendPostFleetSetup({ forceRecreate: true });
await refresh();
} catch (error) {
core.notifications.toasts.addDanger(error.message);
setIsFormLoading(false);
}
};
if (
!missingRequirements.includes('tls_required') &&
!missingRequirements.includes('api_keys') &&
!missingRequirements.includes('encrypted_saved_object_encryption_key_required')
) {
return (
<WithoutHeaderLayout>
<EuiPageBody restrictWidth={648}>
<EuiPageContent
verticalPosition="center"
horizontalPosition="center"
className="eui-textCenter"
paddingSize="l"
>
<EuiSpacer size="m" />
<EuiIcon type="lock" color="subdued" size="xl" />
<EuiSpacer size="m" />
<EuiTitle size="l">
<h2>
<FormattedMessage
id="xpack.fleet.setupPage.enableTitle"
defaultMessage="Enable central management for Elastic Agents"
/>
</h2>
</EuiTitle>
<EuiSpacer size="xl" />
<EuiText color="subdued">
<FormattedMessage
id="xpack.fleet.setupPage.enableText"
defaultMessage="Central management requires an Elastic user who can create API keys and write to logs-* and metrics-*."
/>
</EuiText>
<EuiSpacer size="l" />
<EuiForm>
<EuiButton onClick={onSubmit} fill isLoading={isFormLoading} type="submit">
<FormattedMessage
id="xpack.fleet.setupPage.enableCentralManagement"
defaultMessage="Create user and enable central management"
/>
</EuiButton>
</EuiForm>
<EuiSpacer size="m" />
</EuiPageContent>
</EuiPageBody>
</WithoutHeaderLayout>
);
}
return (
<WithoutHeaderLayout>
<EuiPageBody restrictWidth={820}>
<EuiPageContent>
<EuiCallOut
title={i18n.translate('xpack.fleet.setupPage.missingRequirementsCalloutTitle', {
defaultMessage: 'Missing security requirements',
})}
color="warning"
iconType="alert"
>
<FormattedMessage
id="xpack.fleet.setupPage.missingRequirementsCalloutDescription"
defaultMessage="To use central management for Elastic Agents, enable the following Elasticsearch and Kibana security features."
/>
</EuiCallOut>
<EuiSpacer size="m" />
<FormattedMessage
id="xpack.fleet.setupPage.missingRequirementsElasticsearchTitle"
defaultMessage="In your Elasticsearch policy, enable:"
/>
<EuiSpacer size="l" />
<RequirementItem isMissing={false}>
<FormattedMessage
id="xpack.fleet.setupPage.elasticsearchSecurityFlagText"
defaultMessage="{esSecurityLink}. Set {securityFlag} to {true} ."
values={{
esSecurityLink: (
<EuiLink
href="https://www.elastic.co/guide/en/elasticsearch/reference/current/configuring-security.html"
target="_blank"
external
>
<FormattedMessage
id="xpack.fleet.setupPage.elasticsearchSecurityLink"
defaultMessage="Elasticsearch security"
/>
</EuiLink>
),
securityFlag: <EuiCode>xpack.security.enabled</EuiCode>,
true: <EuiCode>true</EuiCode>,
}}
/>
</RequirementItem>
<EuiSpacer size="s" />
<RequirementItem isMissing={missingRequirements.includes('api_keys')}>
<FormattedMessage
id="xpack.fleet.setupPage.elasticsearchApiKeyFlagText"
defaultMessage="{apiKeyLink}. Set {apiKeyFlag} to {true} ."
values={{
apiKeyFlag: <EuiCode>xpack.security.authc.api_key.enabled</EuiCode>,
true: <EuiCode>true</EuiCode>,
apiKeyLink: (
<EuiLink
href="https://www.elastic.co/guide/en/elasticsearch/reference/current/security-settings.html#api-key-service-settings"
target="_blank"
external
>
<FormattedMessage
id="xpack.fleet.setupPage.apiKeyServiceLink"
defaultMessage="API key service"
/>
</EuiLink>
),
}}
/>
</RequirementItem>
<EuiSpacer size="m" />
<EuiCodeBlock isCopyable={true}>
{`xpack.security.enabled: true
xpack.security.authc.api_key.enabled: true`}
</EuiCodeBlock>
<EuiSpacer size="l" />
<FormattedMessage
id="xpack.fleet.setupPage.missingRequirementsKibanaTitle"
defaultMessage="In your Kibana policy, enable:"
/>
<EuiSpacer size="l" />
<RequirementItem isMissing={missingRequirements.includes('tls_required')}>
<FormattedMessage
id="xpack.fleet.setupPage.tlsFlagText"
defaultMessage="{kibanaSecurityLink}. Set {securityFlag} to {true}. For development purposes, you can disable {tlsLink} by setting {tlsFlag} to {true} as an unsafe alternative."
values={{
kibanaSecurityLink: (
<EuiLink
href="https://www.elastic.co/guide/en/kibana/current/using-kibana-with-security.html"
target="_blank"
external
>
<FormattedMessage
id="xpack.fleet.setupPage.kibanaSecurityLink"
defaultMessage="Kibana security"
/>
</EuiLink>
),
securityFlag: <EuiCode>xpack.security.enabled</EuiCode>,
tlsLink: (
<EuiLink
href="https://www.elastic.co/guide/en/kibana/current/configuring-tls.html"
target="_blank"
external
>
<FormattedMessage id="xpack.fleet.setupPage.tlsLink" defaultMessage="TLS" />
</EuiLink>
),
tlsFlag: <EuiCode>xpack.fleet.agents.tlsCheckDisabled</EuiCode>,
true: <EuiCode>true</EuiCode>,
}}
/>
</RequirementItem>
<EuiSpacer size="s" />
<RequirementItem
isMissing={missingRequirements.includes(
'encrypted_saved_object_encryption_key_required'
)}
>
<FormattedMessage
id="xpack.fleet.setupPage.encryptionKeyFlagText"
defaultMessage="{encryptionKeyLink}. Set {keyFlag} to any alphanumeric value of at least 32 characters."
values={{
encryptionKeyLink: (
<EuiLink
href="https://www.elastic.co/guide/en/kibana/current/fleet-settings-kb.html"
target="_blank"
external
>
<FormattedMessage
id="xpack.fleet.setupPage.kibanaEncryptionLink"
defaultMessage="Kibana encryption key"
/>
</EuiLink>
),
keyFlag: <EuiCode>xpack.encryptedSavedObjects.encryptionKey</EuiCode>,
}}
/>
</RequirementItem>
<EuiSpacer size="m" />
<EuiCodeBlock isCopyable={true}>
{`xpack.security.enabled: true
xpack.encryptedSavedObjects.encryptionKey: "something_at_least_32_characters"`}
</EuiCodeBlock>
<EuiSpacer size="l" />
<FormattedMessage
id="xpack.fleet.setupPage.gettingStartedText"
defaultMessage="For more information, read our {link} guide."
values={{
link: (
<EuiLink
href="https://www.elastic.co/guide/en/fleet/current/index.html"
target="_blank"
external
>
<FormattedMessage
id="xpack.fleet.setupPage.gettingStartedLink"
defaultMessage="Getting Started"
/>
</EuiLink>
),
}}
/>
</EuiPageContent>
</EuiPageBody>
</WithoutHeaderLayout>
);
};

View file

@ -23,6 +23,7 @@ import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public';
import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public';
import { Storage } from '../../../../src/plugins/kibana_utils/public';
import type { LicensingPluginSetup } from '../../licensing/public';
import type { CloudSetup } from '../../cloud/public';
import { PLUGIN_ID, setupRouteService, appRoutesService } from '../common';
import type { CheckPermissionsResponse, PostIngestSetupResponse } from '../common';
@ -61,6 +62,7 @@ export interface FleetSetupDeps {
licensing: LicensingPluginSetup;
data: DataPublicPluginSetup;
home?: HomePublicPluginSetup;
cloud?: CloudSetup;
}
export interface FleetStartDeps {
@ -69,6 +71,7 @@ export interface FleetStartDeps {
export interface FleetStartServices extends CoreStart, FleetStartDeps {
storage: Storage;
cloud?: CloudSetup;
}
export class FleetPlugin implements Plugin<FleetSetup, FleetStart, FleetSetupDeps, FleetStartDeps> {
@ -110,6 +113,7 @@ export class FleetPlugin implements Plugin<FleetSetup, FleetStart, FleetSetupDep
...coreStartServices,
...startDepsServices,
storage: this.storage,
cloud: deps.cloud,
};
const { renderApp, teardownFleet } = await import('./applications/fleet');
const unmount = renderApp(startServices, params, config, kibanaVersion, extensions);

View file

@ -50,6 +50,7 @@ export {
DEFAULT_AGENT_POLICY,
DEFAULT_OUTPUT,
// Fleet Server index
FLEET_SERVER_SERVERS_INDEX,
ENROLLMENT_API_KEYS_INDEX,
AGENTS_INDEX,
PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE,

View file

@ -13,7 +13,7 @@ import { createAppContextStartContractMock, xpackMocks } from '../../mocks';
import { appContextService } from '../../services/app_context';
import { setupIngestManager } from '../../services/setup';
import { FleetSetupHandler } from './handlers';
import { fleetSetupHandler } from './handlers';
jest.mock('../../services/setup', () => {
return {
@ -48,7 +48,7 @@ describe('FleetSetupHandler', () => {
mockSetupIngestManager.mockImplementation(() =>
Promise.resolve({ isInitialized: true, preconfigurationError: undefined })
);
await FleetSetupHandler(context, request, response);
await fleetSetupHandler(context, request, response);
const expectedBody: PostIngestSetupResponse = { isInitialized: true };
expect(response.customError).toHaveBeenCalledTimes(0);
@ -59,7 +59,7 @@ describe('FleetSetupHandler', () => {
mockSetupIngestManager.mockImplementation(() =>
Promise.reject(new Error('SO method mocked to throw'))
);
await FleetSetupHandler(context, request, response);
await fleetSetupHandler(context, request, response);
expect(response.customError).toHaveBeenCalledTimes(1);
expect(response.customError).toHaveBeenCalledWith({
@ -75,7 +75,7 @@ describe('FleetSetupHandler', () => {
Promise.reject(new RegistryError('Registry method mocked to throw'))
);
await FleetSetupHandler(context, request, response);
await fleetSetupHandler(context, request, response);
expect(response.customError).toHaveBeenCalledTimes(1);
expect(response.customError).toHaveBeenCalledWith({
statusCode: 502,

View file

@ -8,39 +8,31 @@
import type { RequestHandler } from 'src/core/server';
import type { TypeOf } from '@kbn/config-schema';
import { outputService, appContextService } from '../../services';
import { appContextService } from '../../services';
import type { GetFleetStatusResponse, PostIngestSetupResponse } from '../../../common';
import { setupIngestManager, setupFleet } from '../../services/setup';
import type { PostFleetSetupRequestSchema } from '../../types';
import { setupFleet, setupIngestManager } from '../../services/setup';
import { hasFleetServers } from '../../services/fleet_server';
import { defaultIngestErrorHandler } from '../../errors';
import type { PostFleetSetupRequestSchema } from '../../types';
export const getFleetStatusHandler: RequestHandler = async (context, request, response) => {
const soClient = context.core.savedObjects.client;
try {
const isAdminUserSetup = (await outputService.getAdminUser(soClient)) !== null;
const isApiKeysEnabled = await appContextService
.getSecurity()
.authc.apiKeys.areAPIKeysEnabled();
const isTLSEnabled = appContextService.getHttpSetup().getServerInfo().protocol === 'https';
const isProductionMode = appContextService.getIsProductionMode();
const isCloud = appContextService.getCloud()?.isCloudEnabled ?? false;
const isTLSCheckDisabled = appContextService.getConfig()?.agents?.tlsCheckDisabled ?? false;
const isFleetServerSetup = await hasFleetServers(appContextService.getInternalUserESClient());
const canEncrypt = appContextService.getEncryptedSavedObjectsSetup()?.canEncrypt === true;
const missingRequirements: GetFleetStatusResponse['missing_requirements'] = [];
if (!isAdminUserSetup) {
missingRequirements.push('fleet_admin_user');
}
if (!isApiKeysEnabled) {
missingRequirements.push('api_keys');
}
if (!isTLSCheckDisabled && !isCloud && isProductionMode && !isTLSEnabled) {
missingRequirements.push('tls_required');
}
if (!canEncrypt) {
missingRequirements.push('encrypted_saved_object_encryption_key_required');
}
if (!isFleetServerSetup) {
missingRequirements.push('fleet_server');
}
const body: GetFleetStatusResponse = {
isReady: missingRequirements.length === 0,
@ -55,7 +47,23 @@ export const getFleetStatusHandler: RequestHandler = async (context, request, re
}
};
export const createFleetSetupHandler: RequestHandler<
export const fleetSetupHandler: RequestHandler = async (context, request, response) => {
try {
const soClient = context.core.savedObjects.client;
const esClient = context.core.elasticsearch.client.asCurrentUser;
const body: PostIngestSetupResponse = { isInitialized: true };
await setupIngestManager(soClient, esClient);
return response.ok({
body,
});
} catch (error) {
return defaultIngestErrorHandler({ error, response });
}
};
// TODO should be removed as part https://github.com/elastic/kibana/issues/94303
export const fleetAgentSetupHandler: RequestHandler<
undefined,
undefined,
TypeOf<typeof PostFleetSetupRequestSchema.body>
@ -63,10 +71,9 @@ export const createFleetSetupHandler: RequestHandler<
try {
const soClient = context.core.savedObjects.client;
const esClient = context.core.elasticsearch.client.asCurrentUser;
const body = await setupIngestManager(soClient, esClient);
await setupFleet(soClient, esClient, {
forceRecreate: request.body?.forceRecreate ?? false,
});
const body: PostIngestSetupResponse = { isInitialized: true };
await setupIngestManager(soClient, esClient);
await setupFleet(soClient, esClient, { forceRecreate: request.body?.forceRecreate === true });
return response.ok({
body,
@ -75,17 +82,3 @@ export const createFleetSetupHandler: RequestHandler<
return defaultIngestErrorHandler({ error, response });
}
};
export const FleetSetupHandler: RequestHandler = async (context, request, response) => {
const soClient = context.core.savedObjects.client;
const esClient = context.core.elasticsearch.client.asCurrentUser;
try {
const body: PostIngestSetupResponse = await setupIngestManager(soClient, esClient);
return response.ok({
body,
});
} catch (error) {
return defaultIngestErrorHandler({ error, response });
}
};

View file

@ -11,7 +11,7 @@ import { PLUGIN_ID, AGENTS_SETUP_API_ROUTES, SETUP_API_ROUTE } from '../../const
import type { FleetConfigType } from '../../../common';
import { PostFleetSetupRequestSchema } from '../../types';
import { getFleetStatusHandler, createFleetSetupHandler, FleetSetupHandler } from './handlers';
import { getFleetStatusHandler, fleetSetupHandler, fleetAgentSetupHandler } from './handlers';
export const registerFleetSetupRoute = (router: IRouter) => {
router.post(
@ -22,7 +22,7 @@ export const registerFleetSetupRoute = (router: IRouter) => {
// and will see `Unable to initialize Ingest Manager` in the UI
options: { tags: [`access:${PLUGIN_ID}-read`] },
},
FleetSetupHandler
fleetSetupHandler
);
};
@ -33,7 +33,7 @@ export const registerCreateFleetSetupRoute = (router: IRouter) => {
validate: PostFleetSetupRequestSchema,
options: { tags: [`access:${PLUGIN_ID}-all`] },
},
createFleetSetupHandler
fleetAgentSetupHandler
);
};

View file

@ -60,7 +60,6 @@ import { outputService } from './output';
import { agentPolicyUpdateEventHandler } from './agent_policy_update';
import { getSettings } from './settings';
import { normalizeKuery, escapeSearchQueryPhrase } from './saved_object';
import { isAgentsSetup } from './agents/setup';
import { appContextService } from './app_context';
const SAVED_OBJECT_TYPE = AGENT_POLICY_SAVED_OBJECT_TYPE;
@ -226,7 +225,7 @@ class AgentPolicyService {
options
);
if (!agentPolicy.is_default) {
if (!agentPolicy.is_default && !agentPolicy.is_default_fleet_server) {
await this.triggerAgentPolicyUpdatedEvent(soClient, esClient, 'created', newSo.id);
}
@ -603,6 +602,11 @@ class AgentPolicyService {
agentPolicyId: string
) {
const esClient = appContextService.getInternalUserESClient();
const defaultOutputId = await outputService.getDefaultOutputId(soClient);
if (!defaultOutputId) {
return;
}
await this.createFleetPolicyChangeFleetServer(soClient, esClient, agentPolicyId);
@ -614,11 +618,6 @@ class AgentPolicyService {
esClient: ElasticsearchClient,
agentPolicyId: string
) {
// If Agents is not setup skip the creation of POLICY_CHANGE agent actions
// the action will be created during the fleet setup
if (!(await isAgentsSetup(soClient))) {
return;
}
const policy = await agentPolicyService.getFullAgentPolicy(soClient, agentPolicyId);
if (!policy || !policy.revision) {
return;
@ -646,11 +645,6 @@ class AgentPolicyService {
esClient: ElasticsearchClient,
agentPolicyId: string
) {
// If Agents is not setup skip the creation of POLICY_CHANGE agent actions
// the action will be created during the fleet setup
if (!(await isAgentsSetup(soClient))) {
return;
}
const policy = await agentPolicyService.get(soClient, agentPolicyId);
const fullPolicy = await agentPolicyService.getFullAgentPolicy(soClient, agentPolicyId);
if (!policy || !fullPolicy || !fullPolicy.revision) {
@ -674,6 +668,29 @@ class AgentPolicyService {
});
}
public async getLatestFleetPolicy(esClient: ElasticsearchClient, agentPolicyId: string) {
const res = await esClient.search({
index: AGENT_POLICY_INDEX,
ignore_unavailable: true,
body: {
query: {
term: {
policy_id: agentPolicyId,
},
},
size: 1,
sort: [{ revision_idx: { order: 'desc' } }],
},
});
// @ts-expect-error value is number | TotalHits
if (res.body.hits.total.value === 0) {
return null;
}
return res.body.hits.hits[0]._source;
}
public async getFullAgentPolicy(
soClient: SavedObjectsClientContract,
id: string,

View file

@ -9,7 +9,7 @@ import type { KibanaRequest } from 'src/core/server';
import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server';
import { generateEnrollmentAPIKey, deleteEnrollmentApiKeyForAgentPolicyId } from './api_keys';
import { isAgentsSetup, unenrollForAgentPolicyId } from './agents';
import { unenrollForAgentPolicyId } from './agents';
import { agentPolicyService } from './agent_policy';
import { appContextService } from './app_context';
@ -34,17 +34,13 @@ export async function agentPolicyUpdateEventHandler(
action: string,
agentPolicyId: string
) {
// If Agents are not setup skip this hook
if (!(await isAgentsSetup(soClient))) {
return;
}
// `soClient` from ingest `appContextService` is used to create policy change actions
// to ensure encrypted SOs are handled correctly
const internalSoClient = appContextService.getInternalUserSOClient(fakeRequest);
if (action === 'created') {
await generateEnrollmentAPIKey(soClient, esClient, {
name: 'Default',
agentPolicyId,
});
await agentPolicyService.createFleetPolicyChangeAction(internalSoClient, agentPolicyId);

View file

@ -5,14 +5,12 @@
* 2.0.
*/
import type { SavedObjectsClientContract } from 'src/core/server';
import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server';
import { SO_SEARCH_LIMIT } from '../../constants';
import { agentPolicyService } from '../agent_policy';
import { outputService } from '../output';
import { getLatestConfigChangeAction } from './actions';
export async function isAgentsSetup(soClient: SavedObjectsClientContract): Promise<boolean> {
const adminUser = await outputService.getAdminUser(soClient, false);
const outputId = await outputService.getDefaultOutputId(soClient);
@ -26,20 +24,18 @@ export async function isAgentsSetup(soClient: SavedObjectsClientContract): Promi
*
* @param soClient
*/
export async function ensureAgentActionPolicyChangeExists(soClient: SavedObjectsClientContract) {
// If Agents are not setup skip
if (!(await isAgentsSetup(soClient))) {
return;
}
export async function ensureAgentActionPolicyChangeExists(
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient
) {
const { items: agentPolicies } = await agentPolicyService.list(soClient, {
perPage: SO_SEARCH_LIMIT,
});
await Promise.all(
agentPolicies.map(async (agentPolicy) => {
const policyChangeActionExist = !!(await getLatestConfigChangeAction(
soClient,
const policyChangeActionExist = !!(await agentPolicyService.getLatestFleetPolicy(
esClient,
agentPolicy.id
));

View file

@ -11,6 +11,7 @@ import { i18n } from '@kbn/i18n';
import { ResponseError } from '@elastic/elasticsearch/lib/errors';
import type { SavedObjectsClientContract, ElasticsearchClient } from 'src/core/server';
import { esKuery } from '../../../../../../src/plugins/data/server';
import type { ESSearchResponse as SearchResponse } from '../../../../../../typings/elasticsearch';
import type { EnrollmentAPIKey, FleetServerEnrollmentAPIKey } from '../../types';
import { ENROLLMENT_API_KEYS_INDEX } from '../../constants';
@ -39,7 +40,9 @@ export async function listEnrollmentApiKeys(
sort: 'created_at:desc',
track_total_hits: true,
ignore_unavailable: true,
q: kuery,
body: kuery
? { query: esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(kuery)) }
: undefined,
});
// @ts-expect-error @elastic/elasticsearch
@ -54,6 +57,17 @@ export async function listEnrollmentApiKeys(
};
}
export async function hasEnrollementAPIKeysForPolicy(
esClient: ElasticsearchClient,
policyId: string
) {
const res = await listEnrollmentApiKeys(esClient, {
kuery: `policy_id:"${policyId}"`,
});
return res.total !== 0;
}
export async function getEnrollmentAPIKey(
esClient: ElasticsearchClient,
id: string

View file

@ -5,10 +5,12 @@
* 2.0.
*/
import type { ElasticsearchClient } from 'kibana/server';
import { first } from 'rxjs/operators';
import { appContextService } from '../app_context';
import { licenseService } from '../license';
import { FLEET_SERVER_SERVERS_INDEX } from '../../constants';
import { runFleetServerMigration } from './saved_object_migrations';
@ -21,6 +23,19 @@ export function isFleetServerSetup() {
return _isFleetServerSetup;
}
/**
* Check if at least one fleet server is connected
*/
export async function hasFleetServers(esClient: ElasticsearchClient) {
const res = await esClient.search<{}, {}>({
index: FLEET_SERVER_SERVERS_INDEX,
ignore_unavailable: true,
});
// @ts-expect-error value is number | TotalHits
return res.body.hits.total.value > 0;
}
export async function awaitIfFleetServerSetupPending() {
if (!_isPending) {
return;

View file

@ -14,6 +14,8 @@ import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../constants';
import { ensurePreconfiguredPackagesAndPolicies } from './preconfiguration';
jest.mock('./agent_policy_update');
const mockInstalledPackages = new Map();
const mockConfiguredPolicies = new Map();

View file

@ -24,8 +24,7 @@ import {
ensureInstalledPackage,
ensurePackagesCompletedInstall,
} from './epm/packages/install';
import { generateEnrollmentAPIKey } from './api_keys';
import { generateEnrollmentAPIKey, hasEnrollementAPIKeysForPolicy } from './api_keys';
import { settingsService } from '.';
import { awaitIfPending } from './setup_utils';
import { createDefaultSettings } from './settings';
@ -55,7 +54,6 @@ async function createSetupSideEffects(
// packages installed by default
ensureInstalledDefaultPackages(soClient, esClient),
outputService.ensureDefaultOutput(soClient),
updateFleetRoleIfExists(esClient),
settingsService.getSettings(soClient).catch((e: any) => {
if (e.isBoom && e.output.statusCode === 404) {
const defaultSettings = createDefaultSettings();
@ -174,23 +172,46 @@ async function createSetupSideEffects(
}
}
await ensureAgentActionPolicyChangeExists(soClient);
await ensureDefaultEnrollmentAPIKeysExists(soClient, esClient);
await ensureAgentActionPolicyChangeExists(soClient, esClient);
return { isInitialized: true, preconfigurationError };
}
async function updateFleetRoleIfExists(esClient: ElasticsearchClient) {
try {
await esClient.security.getRole({ name: FLEET_ENROLL_ROLE });
} catch (e) {
if (e.statusCode === 404) {
return;
}
throw e;
export async function ensureDefaultEnrollmentAPIKeysExists(
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
options?: { forceRecreate?: boolean }
) {
const security = appContextService.getSecurity();
if (!security) {
return;
}
return putFleetRole(esClient);
if (!(await security.authc.apiKeys.areAPIKeysEnabled())) {
return;
}
const { items: agentPolicies } = await agentPolicyService.list(soClient, {
perPage: SO_SEARCH_LIMIT,
});
await Promise.all(
agentPolicies.map(async (agentPolicy) => {
const hasKey = await hasEnrollementAPIKeysForPolicy(esClient, agentPolicy.id);
if (hasKey) {
return;
}
return generateEnrollmentAPIKey(soClient, esClient, {
name: `Default`,
agentPolicyId: agentPolicy.id,
forceRecreate: true, // Always generate a new enrollment key when Fleet is being set up
});
})
);
}
async function putFleetRole(esClient: ElasticsearchClient) {
@ -208,6 +229,7 @@ async function putFleetRole(esClient: ElasticsearchClient) {
});
}
// TODO Deprecated should be removed as part of https://github.com/elastic/kibana/issues/94303
export async function setupFleet(
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
@ -234,8 +256,6 @@ export async function setupFleet(
},
});
outputService.invalidateCache();
// save fleet admin user
const defaultOutputId = await outputService.getDefaultOutputId(soClient);
if (!defaultOutputId) {
@ -245,31 +265,12 @@ export async function setupFleet(
})
);
}
await outputService.updateOutput(soClient, defaultOutputId, {
fleet_enroll_username: FLEET_ENROLL_USERNAME,
fleet_enroll_password: password,
});
const { items: agentPolicies } = await agentPolicyService.list(soClient, {
perPage: SO_SEARCH_LIMIT,
});
await Promise.all(
agentPolicies.map((agentPolicy) => {
return generateEnrollmentAPIKey(soClient, esClient, {
name: `Default`,
agentPolicyId: agentPolicy.id,
forceRecreate: true, // Always generate a new enrollment key when Fleet is being set up
});
})
);
await Promise.all(
agentPolicies.map((agentPolicy) =>
agentPolicyService.createFleetPolicyChangeAction(soClient, agentPolicy.id)
)
);
outputService.invalidateCache();
}
function generateRandomPassword() {

View file

@ -8677,30 +8677,17 @@
"xpack.fleet.settings.flyoutTitle": "Fleet 設定",
"xpack.fleet.settings.globalOutputTitle": "グローバル出力",
"xpack.fleet.settings.invalidYamlFormatErrorMessage": "無効なYAML形式{reason}",
"xpack.fleet.settings.kibanaUrlDifferentPathOrProtocolError": "各URLのプロトコルとパスは同じでなければなりません",
"xpack.fleet.settings.kibanaUrlEmptyError": "1つ以上のURLが必要です。",
"xpack.fleet.settings.kibanaUrlError": "無効なURL",
"xpack.fleet.settings.kibanaUrlLabel": "Kibana URL",
"xpack.fleet.settings.saveButtonLabel": "設定を保存",
"xpack.fleet.settings.success.message": "設定が保存されました",
"xpack.fleet.setupPage.apiKeyServiceLink": "APIキーサービス",
"xpack.fleet.setupPage.elasticsearchApiKeyFlagText": "{apiKeyLink}.{apiKeyFlag}を{true}に設定します。",
"xpack.fleet.setupPage.elasticsearchSecurityFlagText": "{esSecurityLink}.{securityFlag}を{true}に設定します。",
"xpack.fleet.setupPage.elasticsearchSecurityLink": "Elasticsearchセキュリティ",
"xpack.fleet.setupPage.enableCentralManagement": "ユーザーを作成し、集中管理を有効にする",
"xpack.fleet.setupPage.enableText": "集中管理には、APIキーを作成し、log-*とmetrics-*に書き込むことができるElasticユーザーが必要です。",
"xpack.fleet.setupPage.enableTitle": "ElasticElasticエージェントの集中管理を有効にする",
"xpack.fleet.setupPage.encryptionKeyFlagText": "{encryptionKeyLink}.{keyFlag}を32文字以上の英数字に設定します。",
"xpack.fleet.setupPage.gettingStartedLink": "はじめに",
"xpack.fleet.setupPage.gettingStartedText": "詳細については、{link}ガイドをお読みください。",
"xpack.fleet.setupPage.kibanaEncryptionLink": "Kibana暗号化鍵",
"xpack.fleet.setupPage.kibanaSecurityLink": "Kibanaセキュリティ",
"xpack.fleet.setupPage.missingRequirementsCalloutDescription": "Elasticエージェントの集中管理を使用するには、次のElasticsearchとKibanaセキュリティ機能を有効にする必要があります。",
"xpack.fleet.setupPage.missingRequirementsCalloutTitle": "不足しているセキュリティ要件",
"xpack.fleet.setupPage.missingRequirementsElasticsearchTitle": "Elasticsearchポリシーでは、次のことができます。",
"xpack.fleet.setupPage.missingRequirementsKibanaTitle": "Kibanaポリシーでは、次のことができます。",
"xpack.fleet.setupPage.tlsFlagText": "{kibanaSecurityLink}.{securityFlag}を{true}に設定します。開発目的では、危険な代替として{tlsFlag}を{true}に設定して、{tlsLink}を無効化できます。",
"xpack.fleet.setupPage.tlsLink": "TLS",
"xpack.fleet.unenrollAgents.cancelButtonLabel": "キャンセル",
"xpack.fleet.unenrollAgents.confirmMultipleButtonLabel": "{count}個のエージェントを登録解除",
"xpack.fleet.unenrollAgents.confirmSingleButtonLabel": "エージェントの登録解除",

View file

@ -8762,30 +8762,17 @@
"xpack.fleet.settings.flyoutTitle": "Fleet 设置",
"xpack.fleet.settings.globalOutputTitle": "全局输出",
"xpack.fleet.settings.invalidYamlFormatErrorMessage": "YAML 无效:{reason}",
"xpack.fleet.settings.kibanaUrlDifferentPathOrProtocolError": "对于每个 URL协议和路径必须相同",
"xpack.fleet.settings.kibanaUrlEmptyError": "至少需要一个 URL",
"xpack.fleet.settings.kibanaUrlError": "URL 无效",
"xpack.fleet.settings.kibanaUrlLabel": "Kibana URL",
"xpack.fleet.settings.saveButtonLabel": "保存设置",
"xpack.fleet.settings.success.message": "设置已保存",
"xpack.fleet.setupPage.apiKeyServiceLink": "API 密钥服务",
"xpack.fleet.setupPage.elasticsearchApiKeyFlagText": "{apiKeyLink}。将 {apiKeyFlag} 设置为 {true}。",
"xpack.fleet.setupPage.elasticsearchSecurityFlagText": "{esSecurityLink}。将 {securityFlag} 设置为 {true}。",
"xpack.fleet.setupPage.elasticsearchSecurityLink": "Elasticsearch 安全",
"xpack.fleet.setupPage.enableCentralManagement": "创建用户并启用集中管理",
"xpack.fleet.setupPage.enableText": "集中管理需要可以创建 API 密钥并写入到 logs-* 和 metrics-* 的 Elastic 用户。",
"xpack.fleet.setupPage.enableTitle": "对 Elastic 代理启用集中管理",
"xpack.fleet.setupPage.encryptionKeyFlagText": "{encryptionKeyLink}。将 {keyFlag} 设置为至少 32 个字符的字母数字值。",
"xpack.fleet.setupPage.gettingStartedLink": "入门",
"xpack.fleet.setupPage.gettingStartedText": "有关更多信息,请阅读我们的{link}指南。",
"xpack.fleet.setupPage.kibanaEncryptionLink": "Kibana 加密密钥",
"xpack.fleet.setupPage.kibanaSecurityLink": "Kibana 安全性",
"xpack.fleet.setupPage.missingRequirementsCalloutDescription": "要对 Elastic 代理使用集中管理,请启用下面的 Elasticsearch 和 Kibana 安全功能。",
"xpack.fleet.setupPage.missingRequirementsCalloutTitle": "缺失安全性要求",
"xpack.fleet.setupPage.missingRequirementsElasticsearchTitle": "在 Elasticsearch 策略中,启用:",
"xpack.fleet.setupPage.missingRequirementsKibanaTitle": "在 Kibana 策略中,启用:",
"xpack.fleet.setupPage.tlsFlagText": "{kibanaSecurityLink}。将 {securityFlag} 设置为 {true}。出于开发目的,作为非安全的备用方案可以通过将 {tlsFlag} 设置为 {true} 来禁用 {tlsLink}。",
"xpack.fleet.setupPage.tlsLink": "TLS",
"xpack.fleet.unenrollAgents.cancelButtonLabel": "取消",
"xpack.fleet.unenrollAgents.confirmMultipleButtonLabel": "取消注册 {count} 个代理",
"xpack.fleet.unenrollAgents.confirmSingleButtonLabel": "取消注册代理",

View file

@ -21,6 +21,7 @@ export default function (providerContext: FtrProviderContext) {
beforeEach(async () => {
await esArchiver.unload('fleet/empty_fleet_server');
await esArchiver.load('fleet/agents');
await getService('supertest').post(`/api/fleet/setup`).set('kbn-xsrf', 'xxx').send();
});
setupFleetAndAgents(providerContext);
afterEach(async () => {

View file

@ -29,6 +29,7 @@ export default function (providerContext: FtrProviderContext) {
beforeEach(async () => {
await esArchiver.unload('fleet/empty_fleet_server');
await esArchiver.load('fleet/agents');
await getService('supertest').post(`/api/fleet/setup`).set('kbn-xsrf', 'xxx').send();
const { body: accessAPIKeyBody } = await esClient.security.createApiKey({
body: {
name: `test access api key: ${uuid.v4()}`,

View file

@ -37,7 +37,7 @@ export default function (providerContext: FtrProviderContext) {
.get(`/api/fleet/enrollment-api-keys`)
.expect(200);
expect(apiResponse.total).to.be(4);
expect(apiResponse.total).to.be(3);
expect(apiResponse.list[0]).to.have.keys('id', 'api_key_id', 'name');
});
});

View file

@ -64,59 +64,6 @@ export default function (providerContext: FtrProviderContext) {
}
});
it('should update the fleet_enroll role with new index permissions if one does already exist', async () => {
try {
await es.security.putRole({
name: 'fleet_enroll',
body: {
cluster: ['monitor', 'manage_api_key'],
indices: [
{
names: ['logs-*', 'metrics-*', 'traces-*'],
privileges: ['create_doc', 'indices:admin/auto_create'],
allow_restricted_indices: false,
},
],
applications: [],
run_as: [],
metadata: {},
// @ts-expect-error @elastic/elasticsearch PutRoleRequest.body doesn't declare transient_metadata property
transient_metadata: { enabled: true },
},
});
} catch (e) {
if (e.meta?.statusCode !== 404) {
throw e;
}
}
const { body: apiResponse } = await supertest
.post(`/api/fleet/setup`)
.set('kbn-xsrf', 'xxxx')
.expect(200);
expect(apiResponse.isInitialized).to.be(true);
const { body: roleResponse } = await es.security.getRole({
name: 'fleet_enroll',
});
expect(roleResponse).to.have.key('fleet_enroll');
expect(roleResponse.fleet_enroll).to.eql({
cluster: ['monitor', 'manage_api_key'],
indices: [
{
names: ['logs-*', 'metrics-*', 'traces-*', '.logs-endpoint.diagnostic.collection-*'],
privileges: ['auto_configure', 'create_doc'],
allow_restricted_indices: false,
},
],
applications: [],
run_as: [],
metadata: {},
transient_metadata: { enabled: true },
});
});
it('should install default packages', async () => {
await supertest.post(`/api/fleet/setup`).set('kbn-xsrf', 'xxxx').expect(200);