[Fleet] Add instructions and generation of a service token for Fleet Server onboarding (#97585)
* Don't block standalone agent instructions when not using Fleet server yet * Add service token instructions - UI only * Add route for regenerating fleet server service token * generate tokens instead of regenerate and add error catching and tests * fix i18n typo * i18n fix, add sudo, copy edits * Fix commands * Add missing test file Co-authored-by: Nicolas Chaulet <nicolas.chaulet@elastic.co> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
f0a05e8c81
commit
10e52bb582
|
@ -76,6 +76,7 @@ export const SETTINGS_API_ROUTES = {
|
|||
// App API routes
|
||||
export const APP_API_ROUTES = {
|
||||
CHECK_PERMISSIONS_PATTERN: `${API_ROOT}/check-permissions`,
|
||||
GENERATE_SERVICE_TOKEN_PATTERN: `${API_ROOT}/service-tokens`,
|
||||
};
|
||||
|
||||
// Agent API routes
|
||||
|
|
|
@ -164,6 +164,7 @@ export const settingsRoutesService = {
|
|||
|
||||
export const appRoutesService = {
|
||||
getCheckPermissionsPath: () => APP_API_ROUTES.CHECK_PERMISSIONS_PATTERN,
|
||||
getRegenerateServiceTokenPath: () => APP_API_ROUTES.GENERATE_SERVICE_TOKEN_PATTERN,
|
||||
};
|
||||
|
||||
export const enrollmentAPIKeyRouteService = {
|
||||
|
|
|
@ -9,3 +9,8 @@ export interface CheckPermissionsResponse {
|
|||
error?: 'MISSING_SECURITY' | 'MISSING_SUPERUSER_ROLE';
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface GenerateServiceTokenResponse {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { appRoutesService } from '../../services';
|
||||
import type { CheckPermissionsResponse } from '../../types';
|
||||
import type { CheckPermissionsResponse, GenerateServiceTokenResponse } from '../../types';
|
||||
|
||||
import { sendRequest } from './use_request';
|
||||
|
||||
|
@ -16,3 +16,10 @@ export const sendGetPermissionsCheck = () => {
|
|||
method: 'get',
|
||||
});
|
||||
};
|
||||
|
||||
export const sendGenerateServiceToken = () => {
|
||||
return sendRequest<GenerateServiceTokenResponse>({
|
||||
path: appRoutesService.getRegenerateServiceTokenPath(),
|
||||
method: 'post',
|
||||
});
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiFlexGroup,
|
||||
|
@ -16,62 +16,229 @@ import {
|
|||
EuiText,
|
||||
EuiLink,
|
||||
EuiEmptyPrompt,
|
||||
EuiSteps,
|
||||
EuiCodeBlock,
|
||||
EuiCallOut,
|
||||
EuiSelect,
|
||||
} from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import { useStartServices } from '../../../hooks';
|
||||
import { DownloadStep } from '../components/agent_enrollment_flyout/steps';
|
||||
import { useStartServices, useGetOutputs, sendGenerateServiceToken } from '../../../hooks';
|
||||
|
||||
const FlexItemWithMinWidth = styled(EuiFlexItem)`
|
||||
min-width: 0px;
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
export const ContentWrapper = styled(EuiFlexGroup)`
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
max-width: 800px;
|
||||
`;
|
||||
|
||||
function renderOnPremInstructions() {
|
||||
// Otherwise the copy button is over the text
|
||||
const CommandCode = styled.pre({
|
||||
overflow: 'scroll',
|
||||
});
|
||||
|
||||
type PLATFORM_TYPE = 'linux-mac' | 'windows' | 'rpm-deb';
|
||||
const PLATFORM_OPTIONS: Array<{ text: string; value: PLATFORM_TYPE }> = [
|
||||
{ text: 'Linux / macOS', value: 'linux-mac' },
|
||||
{ text: 'Windows', value: 'windows' },
|
||||
{ text: 'RPM / DEB', value: 'rpm-deb' },
|
||||
];
|
||||
|
||||
const OnPremInstructions: React.FC = () => {
|
||||
const outputsRequest = useGetOutputs();
|
||||
const { notifications } = useStartServices();
|
||||
const [serviceToken, setServiceToken] = useState<string>();
|
||||
const [isLoadingServiceToken, setIsLoadingServiceToken] = useState<boolean>(false);
|
||||
const [platform, setPlatform] = useState<PLATFORM_TYPE>('linux-mac');
|
||||
|
||||
const output = outputsRequest.data?.items?.[0];
|
||||
const esHost = output?.hosts?.[0];
|
||||
|
||||
const installCommand = useMemo((): string => {
|
||||
if (!serviceToken || !esHost) {
|
||||
return '';
|
||||
}
|
||||
switch (platform) {
|
||||
case 'linux-mac':
|
||||
return `sudo ./elastic-agent install -f --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}`;
|
||||
case 'windows':
|
||||
return `.\\elastic-agent.exe install --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}`;
|
||||
case 'rpm-deb':
|
||||
return `sudo elastic-agent enroll -f --fleet-server-es=${esHost} --fleet-server-service-token=${serviceToken}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}, [serviceToken, esHost, platform]);
|
||||
|
||||
const getServiceToken = useCallback(async () => {
|
||||
setIsLoadingServiceToken(true);
|
||||
try {
|
||||
const { data } = await sendGenerateServiceToken();
|
||||
if (data?.value) {
|
||||
setServiceToken(data?.value);
|
||||
}
|
||||
} catch (err) {
|
||||
notifications.toasts.addError(err, {
|
||||
title: i18n.translate('xpack.fleet.fleetServerSetup.errorGeneratingTokenTitleText', {
|
||||
defaultMessage: 'Error generating token',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
setIsLoadingServiceToken(false);
|
||||
}, [notifications]);
|
||||
|
||||
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={
|
||||
<EuiPanel paddingSize="l" grow={false} hasShadow={false} hasBorder={true}>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiText className="eui-textCenter">
|
||||
<h2>
|
||||
<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."
|
||||
id="xpack.fleet.fleetServerSetup.setupTitle"
|
||||
defaultMessage="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>
|
||||
}
|
||||
</h2>
|
||||
<EuiSpacer size="m" />
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.fleetServerSetup.setupText"
|
||||
defaultMessage="A Fleet Server is required before you can enroll agents with Fleet. See the {userGuideLink} for more information."
|
||||
values={{
|
||||
userGuideLink: (
|
||||
<EuiLink href="https://ela.st/add-fleet-server" external>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.fleetServerSetup.setupGuideLink"
|
||||
defaultMessage="Fleet User Guide"
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiSteps
|
||||
className="eui-textLeft"
|
||||
steps={[
|
||||
DownloadStep(),
|
||||
{
|
||||
title: i18n.translate('xpack.fleet.fleetServerSetup.stepGenerateServiceTokenTitle', {
|
||||
defaultMessage: 'Generate a service token',
|
||||
}),
|
||||
children: (
|
||||
<>
|
||||
<EuiText>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.fleetServerSetup.generateServiceTokenDescription"
|
||||
defaultMessage="A service token grants Fleet Server permissions to write to Elasticsearch."
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiSpacer size="m" />
|
||||
{!serviceToken ? (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
fill
|
||||
isLoading={isLoadingServiceToken}
|
||||
isDisabled={isLoadingServiceToken}
|
||||
onClick={() => {
|
||||
getServiceToken();
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.fleetServerSetup.generateServiceTokenButton"
|
||||
defaultMessage="Generate service token"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
) : (
|
||||
<>
|
||||
<EuiCallOut size="s">
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.fleetServerSetup.saveServiceTokenDescription"
|
||||
defaultMessage="Save your service token information. This will be shown only once."
|
||||
/>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.fleetServerSetup.serviceTokenLabel"
|
||||
defaultMessage="Service token"
|
||||
/>
|
||||
</strong>
|
||||
</EuiFlexItem>
|
||||
<FlexItemWithMinWidth>
|
||||
<EuiCodeBlock paddingSize="m" isCopyable>
|
||||
<CommandCode>{serviceToken}</CommandCode>
|
||||
</EuiCodeBlock>
|
||||
</FlexItemWithMinWidth>
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.fleet.fleetServerSetup.stepInstallAgentTitle', {
|
||||
defaultMessage: 'Install the Elastic Agent as a Fleet Server',
|
||||
}),
|
||||
status: !serviceToken ? 'disabled' : undefined,
|
||||
children: serviceToken ? (
|
||||
<>
|
||||
<EuiText>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.fleetServerSetup.installAgentDescription"
|
||||
defaultMessage="From the agent directory, run the appropriate command to install, enroll, and start an Elastic Agent as a Fleet Server. Requires administrator privileges."
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiSelect
|
||||
prepend={
|
||||
<EuiText>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.fleetServerSetup.platformSelectLabel"
|
||||
defaultMessage="Platform"
|
||||
/>
|
||||
</EuiText>
|
||||
}
|
||||
options={PLATFORM_OPTIONS}
|
||||
value={platform}
|
||||
onChange={(e) => setPlatform(e.target.value as PLATFORM_TYPE)}
|
||||
aria-label={i18n.translate(
|
||||
'xpack.fleet.fleetServerSetup.platformSelectAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Platform',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiCodeBlock
|
||||
fontSize="m"
|
||||
isCopyable={true}
|
||||
paddingSize="m"
|
||||
language="console"
|
||||
whiteSpace="pre"
|
||||
>
|
||||
<CommandCode>{installCommand}</CommandCode>
|
||||
</EuiCodeBlock>
|
||||
</>
|
||||
) : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function renderCloudInstructions(deploymentUrl: string) {
|
||||
const CloudInstructions: React.FC<{ deploymentUrl: string }> = ({ deploymentUrl }) => {
|
||||
return (
|
||||
<EuiPanel
|
||||
paddingSize="none"
|
||||
|
@ -126,7 +293,7 @@ function renderCloudInstructions(deploymentUrl: string) {
|
|||
/>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const FleetServerRequirementPage = () => {
|
||||
const startService = useStartServices();
|
||||
|
@ -134,11 +301,16 @@ export const FleetServerRequirementPage = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ContentWrapper justifyContent="center" alignItems="center">
|
||||
<ContentWrapper gutterSize="l" justifyContent="center" alignItems="center" direction="column">
|
||||
<FlexItemWithMinWidth grow={false}>
|
||||
{deploymentUrl ? (
|
||||
<CloudInstructions deploymentUrl={deploymentUrl} />
|
||||
) : (
|
||||
<OnPremInstructions />
|
||||
)}
|
||||
</FlexItemWithMinWidth>
|
||||
<EuiFlexItem grow={false}>
|
||||
{deploymentUrl ? renderCloudInstructions(deploymentUrl) : renderOnPremInstructions()}
|
||||
<EuiSpacer size="l" />
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center" justifyContent="center">
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLoadingSpinner size="m" />
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -129,12 +129,12 @@ export const AgentEnrollmentFlyout: React.FunctionComponent<Props> = ({
|
|||
|
||||
<EuiFlyoutBody
|
||||
banner={
|
||||
fleetServerHosts.length === 0 ? (
|
||||
fleetServerHosts.length === 0 && mode === 'managed' ? (
|
||||
<MissingFleetServerHostCallout onClose={onClose} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{fleetServerHosts.length === 0 ? null : mode === 'managed' ? (
|
||||
{fleetServerHosts.length === 0 && mode === 'managed' ? null : mode === 'managed' ? (
|
||||
<ManagedInstructions agentPolicies={agentPolicies} />
|
||||
) : (
|
||||
<StandaloneInstructions agentPolicies={agentPolicies} />
|
||||
|
|
|
@ -88,6 +88,7 @@ export {
|
|||
PutSettingsResponse,
|
||||
// API schemas - app
|
||||
CheckPermissionsResponse,
|
||||
GenerateServiceTokenResponse,
|
||||
// EPM types
|
||||
AssetReference,
|
||||
AssetsGroupedByServiceByType,
|
||||
|
|
|
@ -47,6 +47,7 @@ export class AgentUnenrollmentError extends IngestManagerError {}
|
|||
export class AgentPolicyDeletionError extends IngestManagerError {}
|
||||
|
||||
export class FleetSetupError extends IngestManagerError {}
|
||||
export class GenerateServiceTokenError extends IngestManagerError {}
|
||||
|
||||
export class ArtifactsClientError extends IngestManagerError {}
|
||||
export class ArtifactsClientAccessDeniedError extends IngestManagerError {
|
||||
|
|
|
@ -7,9 +7,10 @@
|
|||
|
||||
import type { IRouter, RequestHandler } from 'src/core/server';
|
||||
|
||||
import { APP_API_ROUTES } from '../../constants';
|
||||
import { PLUGIN_ID, APP_API_ROUTES } from '../../constants';
|
||||
import { appContextService } from '../../services';
|
||||
import type { CheckPermissionsResponse } from '../../../common';
|
||||
import type { CheckPermissionsResponse, GenerateServiceTokenResponse } from '../../../common';
|
||||
import { defaultIngestErrorHandler, GenerateServiceTokenError } from '../../errors';
|
||||
|
||||
export const getCheckPermissionsHandler: RequestHandler = async (context, request, response) => {
|
||||
const body: CheckPermissionsResponse = { success: true };
|
||||
|
@ -35,6 +36,29 @@ export const getCheckPermissionsHandler: RequestHandler = async (context, reques
|
|||
}
|
||||
};
|
||||
|
||||
export const generateServiceTokenHandler: RequestHandler = async (context, request, response) => {
|
||||
const esClient = context.core.elasticsearch.client.asCurrentUser;
|
||||
try {
|
||||
const { body: tokenResponse } = await esClient.transport.request({
|
||||
method: 'POST',
|
||||
path: `_security/service/elastic/fleet-server/credential/token/token-${Date.now()}`,
|
||||
});
|
||||
|
||||
if (tokenResponse.created && tokenResponse.token) {
|
||||
const body: GenerateServiceTokenResponse = tokenResponse.token;
|
||||
return response.ok({
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
const error = new GenerateServiceTokenError('Unable to generate service token');
|
||||
return defaultIngestErrorHandler({ error, response });
|
||||
}
|
||||
} catch (e) {
|
||||
const error = new GenerateServiceTokenError(e);
|
||||
return defaultIngestErrorHandler({ error, response });
|
||||
}
|
||||
};
|
||||
|
||||
export const registerRoutes = (router: IRouter) => {
|
||||
router.get(
|
||||
{
|
||||
|
@ -44,4 +68,13 @@ export const registerRoutes = (router: IRouter) => {
|
|||
},
|
||||
getCheckPermissionsHandler
|
||||
);
|
||||
|
||||
router.post(
|
||||
{
|
||||
path: APP_API_ROUTES.GENERATE_SERVICE_TOKEN_PATTERN,
|
||||
validate: {},
|
||||
options: { tags: [`access:${PLUGIN_ID}-all`] },
|
||||
},
|
||||
generateServiceTokenHandler
|
||||
);
|
||||
};
|
||||
|
|
|
@ -43,5 +43,8 @@ export default function ({ loadTestFile }) {
|
|||
|
||||
// Preconfiguration
|
||||
loadTestFile(require.resolve('./preconfiguration/index'));
|
||||
|
||||
// Service tokens
|
||||
loadTestFile(require.resolve('./service_tokens'));
|
||||
});
|
||||
}
|
||||
|
|
45
x-pack/test/fleet_api_integration/apis/service_tokens.ts
Normal file
45
x-pack/test/fleet_api_integration/apis/service_tokens.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../api_integration/ftr_provider_context';
|
||||
|
||||
export default function (providerContext: FtrProviderContext) {
|
||||
const { getService } = providerContext;
|
||||
const supertest = getService('supertest');
|
||||
const esArchiver = getService('esArchiver');
|
||||
const esClient = getService('es');
|
||||
|
||||
describe('fleet_service_tokens', async () => {
|
||||
before(async () => {
|
||||
await esArchiver.load('empty_kibana');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await esArchiver.unload('empty_kibana');
|
||||
});
|
||||
|
||||
describe('POST /api/fleet/service-tokens', () => {
|
||||
it('should create a valid service account token', async () => {
|
||||
const { body: apiResponse } = await supertest
|
||||
.post(`/api/fleet/service-tokens`)
|
||||
.set('kbn-xsrf', 'xxxx')
|
||||
.expect(200);
|
||||
|
||||
expect(apiResponse).have.property('name');
|
||||
expect(apiResponse).have.property('value');
|
||||
|
||||
const { body: tokensResponse } = await esClient.transport.request({
|
||||
method: 'GET',
|
||||
path: `_security/service/elastic/fleet-server/credential`,
|
||||
});
|
||||
|
||||
expect(tokensResponse.tokens).have.property(apiResponse.name);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue