mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Interactive setup mode (#106881)
* Interactive setup mode * remove first attempt * Added suggestions from code review * Verify CA before writing config * fix i18n path * updated plugin list * Updated page bundle limits * added manual configuration * fetch certificate chain * Fix race condition when calling multiple form methods * Fix i18n errors * added types and refactored slightly * welcome page styling * Remove holdsetup config option * typo * fix build errors * Updated manual configuration form * Remove issuer link * Add tests for decode enrollment token * Removed unused class names * fix issue where credentials got inherited from base config * Added tooltips and text overflow * styling fixes * refactored text truncate * Added unit tests * added suggestions from code review * Fixed typo and tests * Styling fixes * Fix i18n errors * Added suggestions from code review * Added route tests * Explicit type exports * Fix server url * Added unit tests * Added product not supported scenario
This commit is contained in:
parent
234f7f6085
commit
035937a5c1
31 changed files with 2503 additions and 84 deletions
|
@ -112,7 +112,7 @@ pageLoadAssetSize:
|
|||
expressionImage: 19288
|
||||
expressionMetric: 22238
|
||||
expressionShape: 34008
|
||||
interactiveSetup: 18532
|
||||
interactiveSetup: 70000
|
||||
expressionTagcloud: 27505
|
||||
expressions: 239290
|
||||
securitySolution: 231753
|
||||
|
|
|
@ -6,5 +6,5 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export type { InteractiveSetupViewState, EnrollmentToken } from './types';
|
||||
export type { InteractiveSetupViewState, EnrollmentToken, Certificate, PingResult } from './types';
|
||||
export { ElasticsearchConnectionStatus } from './elasticsearch_connection_status';
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { PeerCertificate } from 'tls';
|
||||
|
||||
import type { ElasticsearchConnectionStatus } from './elasticsearch_connection_status';
|
||||
|
||||
/**
|
||||
|
@ -43,3 +45,24 @@ export interface EnrollmentToken {
|
|||
*/
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface Certificate {
|
||||
issuer: Partial<PeerCertificate['issuer']>;
|
||||
valid_from: PeerCertificate['valid_from'];
|
||||
valid_to: PeerCertificate['valid_to'];
|
||||
subject: Partial<PeerCertificate['subject']>;
|
||||
fingerprint256: PeerCertificate['fingerprint256'];
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export interface PingResult {
|
||||
/**
|
||||
* Indicates whether the cluster requires authentication.
|
||||
*/
|
||||
authRequired: boolean;
|
||||
|
||||
/**
|
||||
* Full certificate chain of cluster at requested address. Only present if cluster uses HTTPS.
|
||||
*/
|
||||
certificateChain?: Certificate[];
|
||||
}
|
||||
|
|
26
src/plugins/interactive_setup/public/app.scss
Normal file
26
src/plugins/interactive_setup/public/app.scss
Normal file
|
@ -0,0 +1,26 @@
|
|||
.interactiveSetup {
|
||||
@include kibanaFullScreenGraphics;
|
||||
}
|
||||
|
||||
.interactiveSetup__header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
padding: $euiSizeXL;
|
||||
}
|
||||
|
||||
.interactiveSetup__logo {
|
||||
@include kibanaCircleLogo;
|
||||
@include euiBottomShadowMedium;
|
||||
|
||||
margin-bottom: $euiSizeXL;
|
||||
}
|
||||
|
||||
.interactiveSetup__content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
margin: auto;
|
||||
margin-bottom: $euiSizeXL;
|
||||
max-width: map-get($euiBreakpoints, 's') - $euiSizeXL;
|
||||
padding-left: $euiSizeXL;
|
||||
padding-right: $euiSizeXL;
|
||||
}
|
|
@ -6,22 +6,76 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EuiPageTemplate, EuiPanel, EuiText } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import './app.scss';
|
||||
|
||||
import { EuiIcon, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import { ClusterAddressForm } from './cluster_address_form';
|
||||
import type { ClusterConfigurationFormProps } from './cluster_configuration_form';
|
||||
import { ClusterConfigurationForm } from './cluster_configuration_form';
|
||||
import { EnrollmentTokenForm } from './enrollment_token_form';
|
||||
import { ProgressIndicator } from './progress_indicator';
|
||||
|
||||
export const App: FunctionComponent = () => {
|
||||
const [page, setPage] = useState<'token' | 'manual' | 'success'>('token');
|
||||
const [cluster, setCluster] = useState<
|
||||
Omit<ClusterConfigurationFormProps, 'onCancel' | 'onSuccess'>
|
||||
>();
|
||||
|
||||
export const App = () => {
|
||||
return (
|
||||
<EuiPageTemplate
|
||||
restrictWidth={false}
|
||||
template="empty"
|
||||
pageHeader={{
|
||||
iconType: 'logoElastic',
|
||||
pageTitle: 'Welcome to Elastic',
|
||||
}}
|
||||
>
|
||||
<EuiPanel>
|
||||
<EuiText>Kibana server is not ready yet.</EuiText>
|
||||
</EuiPanel>
|
||||
</EuiPageTemplate>
|
||||
<div className="interactiveSetup">
|
||||
<header className="interactiveSetup__header eui-textCenter">
|
||||
<EuiSpacer size="xxl" />
|
||||
<span className="interactiveSetup__logo">
|
||||
<EuiIcon type="logoElastic" size="xxl" />
|
||||
</span>
|
||||
<EuiTitle size="m">
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.app.pageTitle"
|
||||
defaultMessage="Configure Elastic to get started"
|
||||
/>
|
||||
</h1>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="xl" />
|
||||
</header>
|
||||
<div className="interactiveSetup__content">
|
||||
<EuiPanel paddingSize="l">
|
||||
<div hidden={page !== 'token'}>
|
||||
<EnrollmentTokenForm
|
||||
onCancel={() => setPage('manual')}
|
||||
onSuccess={() => setPage('success')}
|
||||
/>
|
||||
</div>
|
||||
<div hidden={page !== 'manual'}>
|
||||
{cluster ? (
|
||||
<ClusterConfigurationForm
|
||||
onCancel={() => setCluster(undefined)}
|
||||
onSuccess={() => setPage('success')}
|
||||
{...cluster}
|
||||
/>
|
||||
) : (
|
||||
<ClusterAddressForm
|
||||
onCancel={() => setPage('token')}
|
||||
onSuccess={(result, values) =>
|
||||
setCluster({
|
||||
host: values.host,
|
||||
authRequired: result.authRequired,
|
||||
certificateChain: result.certificateChain,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{page === 'success' && (
|
||||
<ProgressIndicator onSuccess={() => window.location.replace(window.location.href)} />
|
||||
)}
|
||||
</EuiPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { coreMock } from 'src/core/public/mocks';
|
||||
|
||||
import { ClusterAddressForm } from './cluster_address_form';
|
||||
import { Providers } from './plugin';
|
||||
|
||||
jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
|
||||
htmlIdGenerator: () => () => `id-${Math.random()}`,
|
||||
}));
|
||||
|
||||
describe('ClusterAddressForm', () => {
|
||||
jest.setTimeout(20_000);
|
||||
|
||||
it('calls enrollment API when submitting form', async () => {
|
||||
const coreStart = coreMock.createStart();
|
||||
coreStart.http.post.mockResolvedValue({});
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const { findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<ClusterAddressForm onSuccess={onSuccess} />
|
||||
</Providers>
|
||||
);
|
||||
fireEvent.change(await findByLabelText('Address'), {
|
||||
target: { value: 'https://localhost' },
|
||||
});
|
||||
fireEvent.click(await findByRole('button', { name: 'Check address', hidden: true }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/interactive_setup/ping', {
|
||||
body: JSON.stringify({
|
||||
host: 'https://localhost:9200',
|
||||
}),
|
||||
});
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates form', async () => {
|
||||
const coreStart = coreMock.createStart();
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const { findAllByText, findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<ClusterAddressForm onSuccess={onSuccess} />
|
||||
</Providers>
|
||||
);
|
||||
|
||||
fireEvent.change(await findByLabelText('Address'), {
|
||||
target: { value: 'localhost' },
|
||||
});
|
||||
|
||||
fireEvent.click(await findByRole('button', { name: 'Check address', hidden: true }));
|
||||
|
||||
await findAllByText(/Enter a valid address including protocol/i);
|
||||
|
||||
expect(coreStart.http.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
146
src/plugins/interactive_setup/public/cluster_address_form.tsx
Normal file
146
src/plugins/interactive_setup/public/cluster_address_form.tsx
Normal file
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiCallOut,
|
||||
EuiFieldText,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import type { IHttpFetchError } from 'kibana/public';
|
||||
|
||||
import type { PingResult } from '../common';
|
||||
import type { ValidationErrors } from './use_form';
|
||||
import { useForm } from './use_form';
|
||||
import { useHttp } from './use_http';
|
||||
|
||||
export interface ClusterAddressFormValues {
|
||||
host: string;
|
||||
}
|
||||
|
||||
export interface ClusterAddressFormProps {
|
||||
defaultValues?: ClusterAddressFormValues;
|
||||
onCancel?(): void;
|
||||
onSuccess?(result: PingResult, values: ClusterAddressFormValues): void;
|
||||
}
|
||||
|
||||
export const ClusterAddressForm: FunctionComponent<ClusterAddressFormProps> = ({
|
||||
defaultValues = {
|
||||
host: 'https://localhost:9200',
|
||||
},
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const http = useHttp();
|
||||
|
||||
const [form, eventHandlers] = useForm({
|
||||
defaultValues,
|
||||
validate: async (values) => {
|
||||
const errors: ValidationErrors<typeof values> = {};
|
||||
|
||||
if (!values.host) {
|
||||
errors.host = i18n.translate('interactiveSetup.clusterAddressForm.hostRequiredError', {
|
||||
defaultMessage: 'Enter an address.',
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const url = new URL(values.host);
|
||||
if (!url.protocol || !url.hostname) {
|
||||
throw new Error();
|
||||
}
|
||||
} catch (error) {
|
||||
errors.host = i18n.translate('interactiveSetup.clusterAddressForm.hostInvalidError', {
|
||||
defaultMessage: 'Enter a valid address including protocol.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
const url = new URL(values.host);
|
||||
const host = `${url.protocol}//${url.hostname}:${url.port || 9200}`;
|
||||
|
||||
const result = await http.post<PingResult>('/internal/interactive_setup/ping', {
|
||||
body: JSON.stringify({ host }),
|
||||
});
|
||||
|
||||
onSuccess?.(result, { host });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiForm component="form" noValidate {...eventHandlers}>
|
||||
{form.submitError && (
|
||||
<>
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
title={i18n.translate('interactiveSetup.clusterAddressForm.submitErrorTitle', {
|
||||
defaultMessage: "Couldn't check address",
|
||||
})}
|
||||
>
|
||||
{(form.submitError as IHttpFetchError).body?.message}
|
||||
</EuiCallOut>
|
||||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
|
||||
<EuiFormRow
|
||||
label={i18n.translate('interactiveSetup.clusterAddressForm.hostLabel', {
|
||||
defaultMessage: 'Address',
|
||||
})}
|
||||
error={form.errors.host}
|
||||
isInvalid={form.touched.host && !!form.errors.host}
|
||||
fullWidth
|
||||
>
|
||||
<EuiFieldText
|
||||
name="host"
|
||||
value={form.values.host}
|
||||
isInvalid={form.touched.host && !!form.errors.host}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer />
|
||||
|
||||
<EuiFlexGroup responsive={false} justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty flush="right" iconType="arrowLeft" onClick={onCancel}>
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.clusterAddressForm.cancelButton"
|
||||
defaultMessage="Back"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
type="submit"
|
||||
isLoading={form.isSubmitting}
|
||||
isDisabled={form.isSubmitted && form.isInvalid}
|
||||
fill
|
||||
>
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.clusterAddressForm.submitButton"
|
||||
defaultMessage="{isSubmitting, select, true{Checking address…} other{Check address}}"
|
||||
values={{ isSubmitting: form.isSubmitting }}
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiForm>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { coreMock } from 'src/core/public/mocks';
|
||||
|
||||
import { ClusterConfigurationForm } from './cluster_configuration_form';
|
||||
import { Providers } from './plugin';
|
||||
|
||||
jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
|
||||
htmlIdGenerator: () => () => `id-${Math.random()}`,
|
||||
}));
|
||||
|
||||
describe('ClusterConfigurationForm', () => {
|
||||
jest.setTimeout(20_000);
|
||||
|
||||
it('calls enrollment API when submitting form', async () => {
|
||||
const coreStart = coreMock.createStart();
|
||||
coreStart.http.post.mockResolvedValue({});
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const { findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<ClusterConfigurationForm
|
||||
host="https://localhost:9200"
|
||||
authRequired
|
||||
certificateChain={[
|
||||
{
|
||||
issuer: {},
|
||||
valid_from: '',
|
||||
valid_to: '',
|
||||
subject: {},
|
||||
fingerprint256: '',
|
||||
raw: 'cert',
|
||||
},
|
||||
]}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
fireEvent.change(await findByLabelText('Username'), {
|
||||
target: { value: 'kibana_system' },
|
||||
});
|
||||
fireEvent.change(await findByLabelText('Password'), {
|
||||
target: { value: 'changeme' },
|
||||
});
|
||||
fireEvent.click(await findByLabelText('Certificate authority'));
|
||||
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(coreStart.http.post).toHaveBeenLastCalledWith(
|
||||
'/internal/interactive_setup/configure',
|
||||
{
|
||||
body: JSON.stringify({
|
||||
host: 'https://localhost:9200',
|
||||
username: 'kibana_system',
|
||||
password: 'changeme',
|
||||
caCert: 'cert',
|
||||
}),
|
||||
}
|
||||
);
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates form', async () => {
|
||||
const coreStart = coreMock.createStart();
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const { findAllByText, findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<ClusterConfigurationForm
|
||||
host="https://localhost:9200"
|
||||
authRequired
|
||||
certificateChain={[
|
||||
{
|
||||
issuer: {},
|
||||
valid_from: '',
|
||||
valid_to: '',
|
||||
subject: {},
|
||||
fingerprint256: '',
|
||||
raw: 'cert',
|
||||
},
|
||||
]}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
|
||||
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
|
||||
|
||||
await findAllByText(/Enter a password/i);
|
||||
await findAllByText(/Confirm that you recognize and trust this certificate/i);
|
||||
|
||||
fireEvent.change(await findByLabelText('Username'), {
|
||||
target: { value: 'elastic' },
|
||||
});
|
||||
|
||||
await findAllByText(/User 'elastic' can't be used as Kibana system user/i);
|
||||
|
||||
expect(coreStart.http.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,322 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiCallOut,
|
||||
EuiCheckableCard,
|
||||
EuiFieldPassword,
|
||||
EuiFieldText,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiIcon,
|
||||
EuiLink,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import type { IHttpFetchError } from 'kibana/public';
|
||||
|
||||
import type { Certificate } from '../common';
|
||||
import { TextTruncate } from './text_truncate';
|
||||
import type { ValidationErrors } from './use_form';
|
||||
import { useForm } from './use_form';
|
||||
import { useHtmlId } from './use_html_id';
|
||||
import { useHttp } from './use_http';
|
||||
|
||||
export interface ClusterConfigurationFormValues {
|
||||
username: string;
|
||||
password: string;
|
||||
caCert: string;
|
||||
}
|
||||
|
||||
export interface ClusterConfigurationFormProps {
|
||||
host: string;
|
||||
authRequired: boolean;
|
||||
certificateChain?: Certificate[];
|
||||
defaultValues?: ClusterConfigurationFormValues;
|
||||
onCancel?(): void;
|
||||
onSuccess?(): void;
|
||||
}
|
||||
|
||||
export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFormProps> = ({
|
||||
host,
|
||||
authRequired,
|
||||
certificateChain,
|
||||
defaultValues = {
|
||||
username: 'kibana_system',
|
||||
password: '',
|
||||
caCert: '',
|
||||
},
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const http = useHttp();
|
||||
|
||||
const [form, eventHandlers] = useForm({
|
||||
defaultValues,
|
||||
validate: async (values) => {
|
||||
const errors: ValidationErrors<ClusterConfigurationFormValues> = {};
|
||||
|
||||
if (authRequired) {
|
||||
if (!values.username) {
|
||||
errors.username = i18n.translate(
|
||||
'interactiveSetup.clusterConfigurationForm.usernameRequiredError',
|
||||
{
|
||||
defaultMessage: 'Enter a username.',
|
||||
}
|
||||
);
|
||||
} else if (values.username === 'elastic') {
|
||||
errors.username = i18n.translate(
|
||||
'interactiveSetup.clusterConfigurationForm.usernameReservedError',
|
||||
{
|
||||
defaultMessage: "User 'elastic' can't be used as Kibana system user.",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!values.password) {
|
||||
errors.password = i18n.translate(
|
||||
'interactiveSetup.clusterConfigurationForm.passwordRequiredError',
|
||||
{
|
||||
defaultMessage: `Enter a password.`,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (certificateChain && !values.caCert) {
|
||||
errors.caCert = i18n.translate(
|
||||
'interactiveSetup.clusterConfigurationForm.caCertConfirmationRequiredError',
|
||||
{
|
||||
defaultMessage: 'Confirm that you recognize and trust this certificate.',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
await http.post('/internal/interactive_setup/configure', {
|
||||
body: JSON.stringify({
|
||||
host,
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
caCert: values.caCert,
|
||||
}),
|
||||
});
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
|
||||
const trustCaCertId = useHtmlId('clusterConfigurationForm', 'trustCaCert');
|
||||
|
||||
return (
|
||||
<EuiForm component="form" noValidate {...eventHandlers}>
|
||||
{form.submitError && (
|
||||
<>
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
title={i18n.translate('interactiveSetup.clusterConfigurationForm.submitErrorTitle', {
|
||||
defaultMessage: "Couldn't connect to cluster",
|
||||
})}
|
||||
>
|
||||
{(form.submitError as IHttpFetchError).body?.message}
|
||||
</EuiCallOut>
|
||||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
|
||||
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="s">
|
||||
<EuiFlexItem grow={false} className="eui-textNoWrap">
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.clusterConfigurationForm.connectTo"
|
||||
defaultMessage="Connect to"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} style={{ overflow: 'hidden' }}>
|
||||
<TextTruncate>
|
||||
<strong>{host}</strong>
|
||||
</TextTruncate>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer />
|
||||
|
||||
{authRequired ? (
|
||||
<>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('interactiveSetup.clusterConfigurationForm.usernameLabel', {
|
||||
defaultMessage: 'Username',
|
||||
})}
|
||||
error={form.errors.username}
|
||||
isInvalid={form.touched.username && !!form.errors.username}
|
||||
fullWidth
|
||||
>
|
||||
<EuiFieldText
|
||||
icon="user"
|
||||
name="username"
|
||||
value={form.values.username}
|
||||
isInvalid={form.touched.username && !!form.errors.username}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('interactiveSetup.clusterConfigurationForm.passwordLabel', {
|
||||
defaultMessage: 'Password',
|
||||
})}
|
||||
error={form.errors.password}
|
||||
isInvalid={form.touched.password && !!form.errors.password}
|
||||
fullWidth
|
||||
>
|
||||
<EuiFieldPassword
|
||||
type="dual"
|
||||
name="password"
|
||||
value={form.values.password}
|
||||
isInvalid={form.touched.password && !!form.errors.password}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
title={i18n.translate(
|
||||
'interactiveSetup.clusterConfigurationForm.insecureClusterTitle',
|
||||
{
|
||||
defaultMessage: 'This cluster is not secure.',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.clusterConfigurationForm.insecureClusterDescription"
|
||||
defaultMessage="Anyone with the address could access your data."
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<EuiLink color="warning">
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.clusterConfigurationForm.insecureClusterLink"
|
||||
defaultMessage="Learn how to enable security features."
|
||||
/>
|
||||
</EuiLink>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
|
||||
{certificateChain && certificateChain.length > 0 && (
|
||||
<>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('interactiveSetup.clusterConfigurationForm.caCertLabel', {
|
||||
defaultMessage: 'Certificate authority',
|
||||
})}
|
||||
error={form.errors.caCert}
|
||||
isInvalid={form.touched.caCert && !!form.errors.caCert}
|
||||
fullWidth
|
||||
>
|
||||
<EuiCheckableCard
|
||||
id={trustCaCertId}
|
||||
label={i18n.translate('interactiveSetup.clusterConfigurationForm.trustCaCertLabel', {
|
||||
defaultMessage: 'I recognize and trust this certificate:',
|
||||
})}
|
||||
checkableType="checkbox"
|
||||
value="true"
|
||||
checked={!!form.values.caCert}
|
||||
onChange={() => {
|
||||
const intermediateCa = certificateChain[Math.min(1, certificateChain.length - 1)];
|
||||
form.setValue('caCert', form.values.caCert ? '' : intermediateCa.raw);
|
||||
form.setTouched('caCert');
|
||||
}}
|
||||
>
|
||||
<CertificatePanel certificate={certificateChain[0]} />
|
||||
</EuiCheckableCard>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
|
||||
<EuiFlexGroup responsive={false} justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty flush="right" iconType="arrowLeft" onClick={onCancel}>
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.clusterConfigurationForm.cancelButton"
|
||||
defaultMessage="Back"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
type="submit"
|
||||
isLoading={form.isSubmitting}
|
||||
isDisabled={form.isSubmitted && form.isInvalid}
|
||||
color="primary"
|
||||
fill
|
||||
>
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.clusterConfigurationForm.submitButton"
|
||||
defaultMessage="{isSubmitting, select, true{Connecting to cluster…} other{Connect to cluster}}"
|
||||
values={{ isSubmitting: form.isSubmitting }}
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiForm>
|
||||
);
|
||||
};
|
||||
|
||||
export interface CertificatePanelProps {
|
||||
certificate: Certificate;
|
||||
}
|
||||
|
||||
export const CertificatePanel: FunctionComponent<CertificatePanelProps> = ({ certificate }) => {
|
||||
return (
|
||||
<EuiPanel color="subdued">
|
||||
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="m">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="document" size="l" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="xxs">
|
||||
<h3>{certificate.subject.O || certificate.subject.CN}</h3>
|
||||
</EuiTitle>
|
||||
<EuiText size="xs">
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.certificatePanel.issuer"
|
||||
defaultMessage="Issued by: {issuer}"
|
||||
values={{
|
||||
issuer: certificate.issuer.O || certificate.issuer.CN,
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiText size="xs">
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.certificatePanel.validTo"
|
||||
defaultMessage="Expires: {validTo}"
|
||||
values={{
|
||||
validTo: certificate.valid_to,
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { coreMock } from 'src/core/public/mocks';
|
||||
|
||||
import type { EnrollmentToken } from '../common';
|
||||
import { decodeEnrollmentToken, EnrollmentTokenForm } from './enrollment_token_form';
|
||||
import { Providers } from './plugin';
|
||||
|
||||
jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
|
||||
htmlIdGenerator: () => () => `id-${Math.random()}`,
|
||||
}));
|
||||
|
||||
const token: EnrollmentToken = {
|
||||
ver: '8.0.0',
|
||||
adr: ['localhost:9200'],
|
||||
fgr:
|
||||
'AA:C8:2C:2E:09:58:F4:FE:A1:D2:AB:7F:13:70:C2:7D:EB:FD:A2:23:88:13:E4:DA:3A:D0:59:D0:09:00:07:36',
|
||||
key: 'JH-36HoBo4EYIoVhHh2F:uEo4dksARMq_BSHaAHUr8Q',
|
||||
};
|
||||
|
||||
describe('EnrollmentTokenForm', () => {
|
||||
jest.setTimeout(20_000);
|
||||
|
||||
it('calls enrollment API when submitting form', async () => {
|
||||
const coreStart = coreMock.createStart();
|
||||
coreStart.http.post.mockResolvedValue({});
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const { findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<EnrollmentTokenForm onSuccess={onSuccess} />
|
||||
</Providers>
|
||||
);
|
||||
fireEvent.change(await findByLabelText('Enrollment token'), {
|
||||
target: { value: btoa(JSON.stringify(token)) },
|
||||
});
|
||||
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/interactive_setup/enroll', {
|
||||
body: JSON.stringify({
|
||||
hosts: [`https://${token.adr[0]}`],
|
||||
apiKey: btoa(token.key),
|
||||
caFingerprint: token.fgr,
|
||||
}),
|
||||
});
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates form', async () => {
|
||||
const coreStart = coreMock.createStart();
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const { findAllByText, findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<EnrollmentTokenForm onSuccess={onSuccess} />
|
||||
</Providers>
|
||||
);
|
||||
|
||||
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
|
||||
|
||||
await findAllByText(/Enter an enrollment token/i);
|
||||
|
||||
fireEvent.change(await findByLabelText('Enrollment token'), {
|
||||
target: { value: 'invalid' },
|
||||
});
|
||||
|
||||
await findAllByText(/Enter a valid enrollment token/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeEnrollmentToken', () => {
|
||||
it('should decode a valid token', () => {
|
||||
expect(decodeEnrollmentToken(btoa(JSON.stringify(token)))).toEqual({
|
||||
adr: ['https://localhost:9200'],
|
||||
fgr:
|
||||
'AA:C8:2C:2E:09:58:F4:FE:A1:D2:AB:7F:13:70:C2:7D:EB:FD:A2:23:88:13:E4:DA:3A:D0:59:D0:09:00:07:36',
|
||||
key: 'SkgtMzZIb0JvNEVZSW9WaEhoMkY6dUVvNGRrc0FSTXFfQlNIYUFIVXI4UQ==',
|
||||
ver: '8.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not decode an invalid token', () => {
|
||||
expect(decodeEnrollmentToken(JSON.stringify(token))).toBeUndefined();
|
||||
expect(
|
||||
decodeEnrollmentToken(
|
||||
btoa(
|
||||
JSON.stringify({
|
||||
ver: [''],
|
||||
adr: null,
|
||||
fgr: false,
|
||||
key: undefined,
|
||||
})
|
||||
)
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(decodeEnrollmentToken(btoa(JSON.stringify({})))).toBeUndefined();
|
||||
expect(decodeEnrollmentToken(btoa(JSON.stringify([])))).toBeUndefined();
|
||||
expect(decodeEnrollmentToken(btoa(JSON.stringify(null)))).toBeUndefined();
|
||||
expect(decodeEnrollmentToken(btoa(JSON.stringify('')))).toBeUndefined();
|
||||
});
|
||||
});
|
204
src/plugins/interactive_setup/public/enrollment_token_form.tsx
Normal file
204
src/plugins/interactive_setup/public/enrollment_token_form.tsx
Normal file
|
@ -0,0 +1,204 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiCallOut,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiIcon,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTextArea,
|
||||
} from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import type { IHttpFetchError } from 'kibana/public';
|
||||
|
||||
import type { EnrollmentToken } from '../common';
|
||||
import { TextTruncate } from './text_truncate';
|
||||
import type { ValidationErrors } from './use_form';
|
||||
import { useForm } from './use_form';
|
||||
import { useHttp } from './use_http';
|
||||
|
||||
export interface EnrollmentTokenFormValues {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface EnrollmentTokenFormProps {
|
||||
defaultValues?: EnrollmentTokenFormValues;
|
||||
onCancel?(): void;
|
||||
onSuccess?(): void;
|
||||
}
|
||||
|
||||
export const EnrollmentTokenForm: FunctionComponent<EnrollmentTokenFormProps> = ({
|
||||
defaultValues = {
|
||||
token: '',
|
||||
},
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const http = useHttp();
|
||||
const [form, eventHandlers] = useForm({
|
||||
defaultValues,
|
||||
validate: (values) => {
|
||||
const errors: ValidationErrors<EnrollmentTokenFormValues> = {};
|
||||
|
||||
if (!values.token) {
|
||||
errors.token = i18n.translate('interactiveSetup.enrollmentTokenForm.tokenRequiredError', {
|
||||
defaultMessage: 'Enter an enrollment token.',
|
||||
});
|
||||
} else {
|
||||
const decoded = decodeEnrollmentToken(values.token);
|
||||
if (!decoded) {
|
||||
errors.token = i18n.translate('interactiveSetup.enrollmentTokenForm.tokenInvalidError', {
|
||||
defaultMessage: 'Enter a valid enrollment token.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
const decoded = decodeEnrollmentToken(values.token)!;
|
||||
await http.post('/internal/interactive_setup/enroll', {
|
||||
body: JSON.stringify({
|
||||
hosts: decoded.adr,
|
||||
apiKey: decoded.key,
|
||||
caFingerprint: decoded.fgr,
|
||||
}),
|
||||
});
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
|
||||
const enrollmentToken = decodeEnrollmentToken(form.values.token);
|
||||
|
||||
return (
|
||||
<EuiForm component="form" noValidate {...eventHandlers}>
|
||||
{form.submitError && (
|
||||
<>
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
title={i18n.translate('interactiveSetup.enrollmentTokenForm.submitErrorTitle', {
|
||||
defaultMessage: "Couldn't connect to cluster",
|
||||
})}
|
||||
>
|
||||
{(form.submitError as IHttpFetchError).body?.message}
|
||||
</EuiCallOut>
|
||||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
|
||||
<EuiFormRow
|
||||
label={i18n.translate('interactiveSetup.enrollmentTokenForm.tokenLabel', {
|
||||
defaultMessage: 'Enrollment token',
|
||||
})}
|
||||
error={form.errors.token}
|
||||
isInvalid={form.touched.token && !!form.errors.token}
|
||||
helpText={enrollmentToken && <EnrollmentTokenDetails token={enrollmentToken} />}
|
||||
fullWidth
|
||||
>
|
||||
<EuiTextArea
|
||||
name="token"
|
||||
value={form.values.token}
|
||||
isInvalid={form.touched.token && !!form.errors.token}
|
||||
placeholder={i18n.translate('interactiveSetup.enrollmentTokenForm.tokenPlaceholder', {
|
||||
defaultMessage: 'Paste enrollment token from terminal',
|
||||
})}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer />
|
||||
|
||||
<EuiFlexGroup responsive={false} justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty flush="right" iconType="gear" onClick={onCancel}>
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.enrollmentTokenForm.cancelButton"
|
||||
defaultMessage="Configure manually"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
type="submit"
|
||||
isLoading={form.isSubmitting}
|
||||
isDisabled={form.isSubmitted && form.isInvalid}
|
||||
fill
|
||||
>
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.enrollmentTokenForm.submitButton"
|
||||
defaultMessage="{isSubmitting, select, true{Connecting to cluster…} other{Connect to cluster}}"
|
||||
values={{ isSubmitting: form.isSubmitting }}
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiForm>
|
||||
);
|
||||
};
|
||||
|
||||
interface EnrollmentTokenDetailsProps {
|
||||
token: EnrollmentToken;
|
||||
}
|
||||
|
||||
const EnrollmentTokenDetails: FunctionComponent<EnrollmentTokenDetailsProps> = ({ token }) => (
|
||||
<EuiText size="xs">
|
||||
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="s">
|
||||
<EuiFlexItem grow={false} className="eui-textNoWrap">
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.enrollmentTokenDetails.connectTo"
|
||||
defaultMessage="Connect to"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="lock" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} style={{ overflow: 'hidden' }}>
|
||||
<TextTruncate>{token.adr[0]}</TextTruncate>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="logoElasticsearch" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} className="eui-textNoWrap">
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.enrollmentTokenDetails.elasticsearchVersion"
|
||||
defaultMessage="Elasticsearch (v{version})"
|
||||
values={{ version: token.ver }}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiText>
|
||||
);
|
||||
|
||||
export function decodeEnrollmentToken(enrollmentToken: string) {
|
||||
try {
|
||||
const json = JSON.parse(atob(enrollmentToken)) as EnrollmentToken;
|
||||
if (
|
||||
!Array.isArray(json.adr) ||
|
||||
json.adr.some((adr) => typeof adr !== 'string') ||
|
||||
typeof json.fgr !== 'string' ||
|
||||
typeof json.key !== 'string' ||
|
||||
typeof json.ver !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
...json,
|
||||
adr: json.adr.map((host) => `https://${host}`),
|
||||
key: btoa(json.key),
|
||||
};
|
||||
} catch (error) {} // eslint-disable-line no-empty
|
||||
}
|
|
@ -6,6 +6,6 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { UserSetupPlugin } from './plugin';
|
||||
import { InteractiveSetupPlugin } from './plugin';
|
||||
|
||||
export const plugin = () => new UserSetupPlugin();
|
||||
export const plugin = () => new InteractiveSetupPlugin();
|
||||
|
|
|
@ -6,21 +6,30 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import type { CoreSetup, CoreStart, Plugin } from 'src/core/public';
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
import type { CoreSetup, CoreStart, HttpSetup, Plugin } from 'src/core/public';
|
||||
|
||||
import { App } from './app';
|
||||
import { HttpProvider } from './use_http';
|
||||
|
||||
export class UserSetupPlugin implements Plugin {
|
||||
export class InteractiveSetupPlugin implements Plugin<void, void, {}, {}> {
|
||||
public setup(core: CoreSetup) {
|
||||
core.application.register({
|
||||
id: 'interactiveSetup',
|
||||
title: 'Interactive Setup',
|
||||
title: 'Configure Elastic to get started',
|
||||
appRoute: '/',
|
||||
chromeless: true,
|
||||
mount: (params) => {
|
||||
ReactDOM.render(<App />, params.element);
|
||||
ReactDOM.render(
|
||||
<Providers http={core.http}>
|
||||
<App />
|
||||
</Providers>,
|
||||
params.element
|
||||
);
|
||||
return () => ReactDOM.unmountComponentAtNode(params.element);
|
||||
},
|
||||
});
|
||||
|
@ -28,3 +37,13 @@ export class UserSetupPlugin implements Plugin {
|
|||
|
||||
public start(core: CoreStart) {}
|
||||
}
|
||||
|
||||
export interface ProvidersProps {
|
||||
http: HttpSetup;
|
||||
}
|
||||
|
||||
export const Providers: FunctionComponent<ProvidersProps> = ({ http, children }) => (
|
||||
<I18nProvider>
|
||||
<HttpProvider http={http}>{children}</HttpProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
|
109
src/plugins/interactive_setup/public/progress_indicator.tsx
Normal file
109
src/plugins/interactive_setup/public/progress_indicator.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { EuiStepProps } from '@elastic/eui';
|
||||
import { EuiPanel, EuiSteps } from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
import useTimeoutFn from 'react-use/lib/useTimeoutFn';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { useHttp } from './use_http';
|
||||
|
||||
export interface ProgressIndicatorProps {
|
||||
onSuccess?(): void;
|
||||
}
|
||||
|
||||
export const ProgressIndicator: FunctionComponent<ProgressIndicatorProps> = ({ onSuccess }) => {
|
||||
const http = useHttp();
|
||||
const [status, checkStatus] = useAsyncFn(async () => {
|
||||
let isAvailable: boolean | undefined = false;
|
||||
let isPastPreboot: boolean | undefined = false;
|
||||
try {
|
||||
const { response } = await http.get('/api/status', { asResponse: true });
|
||||
isAvailable = response ? response.status < 500 : undefined;
|
||||
isPastPreboot = response?.headers.get('content-type')?.includes('application/json');
|
||||
} catch ({ response }) {
|
||||
isAvailable = response ? response.status < 500 : undefined;
|
||||
isPastPreboot = response?.headers.get('content-type')?.includes('application/json');
|
||||
}
|
||||
return isAvailable === true && isPastPreboot === true
|
||||
? 'complete'
|
||||
: isAvailable === false
|
||||
? 'unavailable'
|
||||
: isAvailable === true && isPastPreboot === false
|
||||
? 'preboot'
|
||||
: 'unknown';
|
||||
});
|
||||
|
||||
const [, cancelPolling, resetPolling] = useTimeoutFn(checkStatus, 1000);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.value === 'complete') {
|
||||
cancelPolling();
|
||||
onSuccess?.();
|
||||
} else if (status.loading === false) {
|
||||
resetPolling();
|
||||
}
|
||||
}, [status.loading, status.value]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<EuiPanel color="transparent">
|
||||
<LoadingSteps
|
||||
currentStepId={status.value}
|
||||
steps={[
|
||||
{
|
||||
id: 'preboot',
|
||||
title: i18n.translate('interactiveSetup.progressIndicator.prebootStepTitle', {
|
||||
defaultMessage: 'Saving settings',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'unavailable',
|
||||
title: i18n.translate('interactiveSetup.progressIndicator.unavailableStepTitle', {
|
||||
defaultMessage: 'Starting Elastic',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'complete',
|
||||
title: i18n.translate('interactiveSetup.progressIndicator.completeStepTitle', {
|
||||
defaultMessage: 'Completing setup',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
||||
|
||||
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<T>;
|
||||
|
||||
export interface LoadingStepsProps {
|
||||
currentStepId?: string;
|
||||
steps: Array<Optional<EuiStepProps, 'status' | 'children'>>;
|
||||
}
|
||||
|
||||
export const LoadingSteps: FunctionComponent<LoadingStepsProps> = ({ currentStepId, steps }) => {
|
||||
const currentStepIndex = steps.findIndex((step) => step.id === currentStepId);
|
||||
return (
|
||||
<EuiSteps
|
||||
steps={steps.map((step, i) => ({
|
||||
status:
|
||||
i <= currentStepIndex
|
||||
? 'complete'
|
||||
: steps[i - 1]?.id === currentStepId
|
||||
? 'loading'
|
||||
: 'incomplete',
|
||||
children: null,
|
||||
...step,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
39
src/plugins/interactive_setup/public/text_truncate.tsx
Normal file
39
src/plugins/interactive_setup/public/text_truncate.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EuiToolTip } from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
export const TextTruncate: FunctionComponent = ({ children }) => {
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (textRef.current) {
|
||||
const { clientWidth, scrollWidth } = textRef.current;
|
||||
setShowTooltip(scrollWidth > clientWidth);
|
||||
}
|
||||
}, [children]);
|
||||
|
||||
const truncated = (
|
||||
<span ref={textRef} className="eui-displayBlock eui-textTruncate">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (showTooltip) {
|
||||
return (
|
||||
<EuiToolTip position="top" content={children} anchorClassName="eui-displayBlock">
|
||||
{truncated}
|
||||
</EuiToolTip>
|
||||
);
|
||||
}
|
||||
|
||||
return truncated;
|
||||
};
|
209
src/plugins/interactive_setup/public/use_form.ts
Normal file
209
src/plugins/interactive_setup/public/use_form.ts
Normal file
|
@ -0,0 +1,209 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { set } from '@elastic/safer-lodash-set';
|
||||
import { cloneDeep, cloneDeepWith, get } from 'lodash';
|
||||
import type { ChangeEventHandler, FocusEventHandler, ReactEventHandler } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
||||
export type FormReturnTuple<Values, Result> = [FormState<Values, Result>, FormProps];
|
||||
|
||||
export interface FormProps {
|
||||
onSubmit: ReactEventHandler;
|
||||
onChange: ChangeEventHandler<HTMLFormElement & HTMLInputElement>;
|
||||
onBlur: FocusEventHandler<HTMLFormElement & HTMLInputElement>;
|
||||
}
|
||||
|
||||
export interface FormOptions<Values, Result> {
|
||||
onSubmit: SubmitCallback<Values, Result>;
|
||||
validate: ValidateCallback<Values>;
|
||||
defaultValues: Values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns state and {@link HTMLFormElement} event handlers useful for creating
|
||||
* forms with inline validation.
|
||||
*
|
||||
* @see {@link useFormState} if you don't want to use {@link HTMLFormElement}.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const [form, eventHandlers] = useForm({
|
||||
* onSubmit: (values) => apiClient.create(values),
|
||||
* validate: (values) => !values.email ? { email: 'Required' } : {}
|
||||
* });
|
||||
*
|
||||
* <EuiForm component="form" {...eventHandlers}>
|
||||
* <EuiFieldText name="email" isInvalid={form.touched.email && form.errors.email} />
|
||||
* <EuiButton type="submit">Submit</EuiButton>
|
||||
* <EuiForm>
|
||||
* ```
|
||||
*/
|
||||
export function useForm<Values extends FormValues, Result>(
|
||||
options: FormOptions<Values, Result>
|
||||
): FormReturnTuple<Values, Result> {
|
||||
const form = useFormState(options);
|
||||
|
||||
const eventHandlers: FormProps = {
|
||||
onSubmit: (event) => {
|
||||
event.preventDefault();
|
||||
form.submit();
|
||||
},
|
||||
onChange: (event) => {
|
||||
const { name, type, checked, value } = event.target;
|
||||
if (name) {
|
||||
form.setValue(name, type === 'checkbox' ? checked : value);
|
||||
}
|
||||
},
|
||||
onBlur: (event) => {
|
||||
const { name } = event.target;
|
||||
if (name) {
|
||||
form.setTouched(event.target.name);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return [form, eventHandlers];
|
||||
}
|
||||
|
||||
export type FormValues = Record<string, any>;
|
||||
export type SubmitCallback<Values, Result> = (values: Values) => Promise<Result>;
|
||||
export type ValidateCallback<Values> = (
|
||||
values: Values
|
||||
) => ValidationErrors<Values> | Promise<ValidationErrors<Values>>;
|
||||
export type ValidationErrors<Values> = DeepMap<Values, string>;
|
||||
export type TouchedFields<Values> = DeepMap<Values, boolean>;
|
||||
|
||||
export interface FormState<Values, Result> {
|
||||
setValue(name: string, value: any, revalidate?: boolean): Promise<void>;
|
||||
setError(name: string, message: string): void;
|
||||
setTouched(name: string, touched?: boolean, revalidate?: boolean): Promise<void>;
|
||||
reset(values: Values): void;
|
||||
submit(): Promise<Result | undefined>;
|
||||
validate(): Promise<ValidationErrors<Values>>;
|
||||
values: Values;
|
||||
errors: ValidationErrors<Values>;
|
||||
touched: TouchedFields<Values>;
|
||||
isValidating: boolean;
|
||||
isSubmitting: boolean;
|
||||
submitError: Error | undefined;
|
||||
isInvalid: boolean;
|
||||
isSubmitted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns state useful for creating forms with inline validation.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const form = useFormState({
|
||||
* onSubmit: (values) => apiClient.create(values),
|
||||
* validate: (values) => !values.toggle ? { toggle: 'Required' } : {}
|
||||
* });
|
||||
*
|
||||
* <EuiSwitch
|
||||
* checked={form.values.toggle}
|
||||
* onChange={(e) => form.setValue('toggle', e.target.checked)}
|
||||
* onBlur={() => form.setTouched('toggle')}
|
||||
* isInvalid={!!form.errors.toggle}
|
||||
* />
|
||||
* <EuiButton onClick={form.submit}>
|
||||
* Submit
|
||||
* </EuiButton>
|
||||
* ```
|
||||
*/
|
||||
export function useFormState<Values extends FormValues, Result>({
|
||||
onSubmit,
|
||||
validate,
|
||||
defaultValues,
|
||||
}: FormOptions<Values, Result>): FormState<Values, Result> {
|
||||
const valuesRef = useRef<Values>(defaultValues);
|
||||
const errorsRef = useRef<ValidationErrors<Values>>({});
|
||||
const touchedRef = useRef<TouchedFields<Values>>({});
|
||||
const submitCountRef = useRef(0);
|
||||
|
||||
const [validationState, validateForm] = useAsyncFn(async (formValues: Values) => {
|
||||
const nextErrors = await validate(formValues);
|
||||
errorsRef.current = nextErrors;
|
||||
if (Object.keys(nextErrors).length === 0) {
|
||||
submitCountRef.current = 0;
|
||||
}
|
||||
return nextErrors;
|
||||
}, []);
|
||||
|
||||
const [submitState, submitForm] = useAsyncFn(async (formValues: Values) => {
|
||||
const nextErrors = await validateForm(formValues);
|
||||
touchedRef.current = mapDeep(formValues, true);
|
||||
submitCountRef.current += 1;
|
||||
if (Object.keys(nextErrors).length === 0) {
|
||||
return onSubmit(formValues);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
setValue: async (name, value, revalidate = true) => {
|
||||
const nextValues = setDeep(valuesRef.current, name, value);
|
||||
valuesRef.current = nextValues;
|
||||
if (revalidate) {
|
||||
await validateForm(nextValues);
|
||||
}
|
||||
},
|
||||
setTouched: async (name, touched = true, revalidate = true) => {
|
||||
touchedRef.current = setDeep(touchedRef.current, name, touched);
|
||||
if (revalidate) {
|
||||
await validateForm(valuesRef.current);
|
||||
}
|
||||
},
|
||||
setError: (name, message) => {
|
||||
errorsRef.current = setDeep(errorsRef.current, name, message);
|
||||
touchedRef.current = setDeep(touchedRef.current, name, true);
|
||||
},
|
||||
reset: (nextValues) => {
|
||||
valuesRef.current = nextValues;
|
||||
errorsRef.current = {};
|
||||
touchedRef.current = {};
|
||||
submitCountRef.current = 0;
|
||||
},
|
||||
submit: () => submitForm(valuesRef.current),
|
||||
validate: () => validateForm(valuesRef.current),
|
||||
values: valuesRef.current,
|
||||
errors: errorsRef.current,
|
||||
touched: touchedRef.current,
|
||||
isValidating: validationState.loading,
|
||||
isSubmitting: submitState.loading,
|
||||
submitError: submitState.error,
|
||||
isInvalid: Object.keys(errorsRef.current).length > 0,
|
||||
isSubmitted: submitCountRef.current > 0,
|
||||
};
|
||||
}
|
||||
|
||||
type DeepMap<T, TValue> = {
|
||||
[K in keyof T]?: T[K] extends any[]
|
||||
? T[K][number] extends object
|
||||
? Array<DeepMap<T[K][number], TValue>>
|
||||
: TValue
|
||||
: T[K] extends object
|
||||
? DeepMap<T[K], TValue>
|
||||
: TValue;
|
||||
};
|
||||
|
||||
function mapDeep<T, V>(values: T, value: V): DeepMap<T, V> {
|
||||
return cloneDeepWith(values, (v) => {
|
||||
if (typeof v !== 'object' && v !== null) {
|
||||
return value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setDeep<T extends object, V>(values: T, name: string, value: V): T {
|
||||
if (get(values, name) !== value) {
|
||||
return set(cloneDeep(values), name, value);
|
||||
}
|
||||
return values;
|
||||
}
|
29
src/plugins/interactive_setup/public/use_html_id.ts
Normal file
29
src/plugins/interactive_setup/public/use_html_id.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { htmlIdGenerator } from '@elastic/eui';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Generates an ID that can be used for HTML elements.
|
||||
*
|
||||
* @param prefix Prefix of the id to be generated
|
||||
* @param suffix Suffix of the id to be generated
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const titleId = useHtmlId('changePasswordForm', 'title');
|
||||
*
|
||||
* <EuiForm aria-labelledby={titleId}>
|
||||
* <h2 id={titleId}>Change password</h2>
|
||||
* </EuiForm>
|
||||
* ```
|
||||
*/
|
||||
export function useHtmlId(prefix?: string, suffix?: string) {
|
||||
return useMemo(() => htmlIdGenerator(prefix)(suffix), [prefix, suffix]);
|
||||
}
|
15
src/plugins/interactive_setup/public/use_http.ts
Normal file
15
src/plugins/interactive_setup/public/use_http.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import constate from 'constate';
|
||||
|
||||
import type { HttpSetup } from 'src/core/public';
|
||||
|
||||
export const [HttpProvider, useHttp] = constate(({ http }: { http: HttpSetup }) => {
|
||||
return http;
|
||||
});
|
|
@ -16,5 +16,7 @@ export const elasticsearchServiceMock = {
|
|||
ElasticsearchConnectionStatus.Configured
|
||||
),
|
||||
enroll: jest.fn(),
|
||||
authenticate: jest.fn(),
|
||||
ping: jest.fn(),
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import { errors } from '@elastic/elasticsearch';
|
||||
import tls from 'tls';
|
||||
|
||||
import { nextTick } from '@kbn/test/jest';
|
||||
import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks';
|
||||
|
@ -17,6 +18,10 @@ import type { ElasticsearchServiceSetup } from './elasticsearch_service';
|
|||
import { ElasticsearchService } from './elasticsearch_service';
|
||||
import { interactiveSetupMock } from './mocks';
|
||||
|
||||
jest.mock('tls');
|
||||
|
||||
const tlsConnectMock = tls.connect as jest.MockedFunction<typeof tls.connect>;
|
||||
|
||||
describe('ElasticsearchService', () => {
|
||||
let service: ElasticsearchService;
|
||||
let mockElasticsearchPreboot: ReturnType<typeof elasticsearchServiceMock.createPreboot>;
|
||||
|
@ -33,17 +38,21 @@ describe('ElasticsearchService', () => {
|
|||
let mockAuthenticateClient: ReturnType<
|
||||
typeof elasticsearchServiceMock.createCustomClusterClient
|
||||
>;
|
||||
let mockPingClient: ReturnType<typeof elasticsearchServiceMock.createCustomClusterClient>;
|
||||
let setupContract: ElasticsearchServiceSetup;
|
||||
beforeEach(() => {
|
||||
mockConnectionStatusClient = elasticsearchServiceMock.createCustomClusterClient();
|
||||
mockEnrollClient = elasticsearchServiceMock.createCustomClusterClient();
|
||||
mockAuthenticateClient = elasticsearchServiceMock.createCustomClusterClient();
|
||||
mockPingClient = elasticsearchServiceMock.createCustomClusterClient();
|
||||
mockElasticsearchPreboot.createClient.mockImplementation((type) => {
|
||||
switch (type) {
|
||||
case 'enroll':
|
||||
return mockEnrollClient;
|
||||
case 'authenticate':
|
||||
return mockAuthenticateClient;
|
||||
case 'ping':
|
||||
return mockPingClient;
|
||||
default:
|
||||
return mockConnectionStatusClient;
|
||||
}
|
||||
|
@ -414,7 +423,7 @@ some weird+ca/with
|
|||
caFingerprint: 'DE:AD:BE:EF',
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ca: expectedCa,
|
||||
caCert: expectedCa,
|
||||
host: 'host2',
|
||||
serviceAccountToken: {
|
||||
name: 'some-name',
|
||||
|
@ -478,6 +487,133 @@ some weird+ca/with
|
|||
expect(mockAuthenticateClient.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#authenticate()', () => {
|
||||
it('fails if ping call fails', async () => {
|
||||
mockAuthenticateClient.asInternalUser.ping.mockRejectedValue(
|
||||
new errors.ConnectionError(
|
||||
'some-message',
|
||||
interactiveSetupMock.createApiResponse({ body: {} })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
setupContract.authenticate({ host: 'http://localhost:9200' })
|
||||
).rejects.toMatchInlineSnapshot(`[ConnectionError: some-message]`);
|
||||
});
|
||||
|
||||
it('succeeds if ping call succeeds', async () => {
|
||||
mockAuthenticateClient.asInternalUser.ping.mockResolvedValue(
|
||||
interactiveSetupMock.createApiResponse({ statusCode: 200, body: true })
|
||||
);
|
||||
|
||||
await expect(
|
||||
setupContract.authenticate({ host: 'http://localhost:9200' })
|
||||
).resolves.toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#ping()', () => {
|
||||
it('fails if host is not reachable', async () => {
|
||||
mockPingClient.asInternalUser.ping.mockRejectedValue(
|
||||
new errors.ConnectionError(
|
||||
'some-message',
|
||||
interactiveSetupMock.createApiResponse({ body: {} })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(setupContract.ping('http://localhost:9200')).rejects.toMatchInlineSnapshot(
|
||||
`[ConnectionError: some-message]`
|
||||
);
|
||||
});
|
||||
|
||||
it('fails if host is not supported', async () => {
|
||||
mockPingClient.asInternalUser.ping.mockRejectedValue(
|
||||
new errors.ProductNotSupportedError(interactiveSetupMock.createApiResponse({ body: {} }))
|
||||
);
|
||||
|
||||
await expect(setupContract.ping('http://localhost:9200')).rejects.toMatchInlineSnapshot(
|
||||
`[ProductNotSupportedError: The client noticed that the server is not Elasticsearch and we do not support this unknown product.]`
|
||||
);
|
||||
});
|
||||
|
||||
it('succeeds if host does not require authentication', async () => {
|
||||
mockPingClient.asInternalUser.ping.mockResolvedValue(
|
||||
interactiveSetupMock.createApiResponse({ statusCode: 200, body: true })
|
||||
);
|
||||
|
||||
await expect(setupContract.ping('http://localhost:9200')).resolves.toEqual({
|
||||
authRequired: false,
|
||||
certificateChain: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('succeeds if host requires authentication', async () => {
|
||||
mockPingClient.asInternalUser.ping.mockRejectedValue(
|
||||
new errors.ResponseError(
|
||||
interactiveSetupMock.createApiResponse({ statusCode: 401, body: {} })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(setupContract.ping('http://localhost:9200')).resolves.toEqual({
|
||||
authRequired: true,
|
||||
certificateChain: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('succeeds if host requires SSL', async () => {
|
||||
mockPingClient.asInternalUser.ping.mockRejectedValue(
|
||||
new errors.ResponseError(
|
||||
interactiveSetupMock.createApiResponse({ statusCode: 401, body: {} })
|
||||
)
|
||||
);
|
||||
|
||||
tlsConnectMock.mockReturnValue(({
|
||||
once: jest.fn((event, fn) => {
|
||||
if (event === 'secureConnect') {
|
||||
fn();
|
||||
}
|
||||
}),
|
||||
getPeerCertificate: jest.fn().mockReturnValue({ raw: Buffer.from('cert') }),
|
||||
destroy: jest.fn(),
|
||||
} as unknown) as tls.TLSSocket);
|
||||
|
||||
await expect(setupContract.ping('https://localhost:9200')).resolves.toEqual({
|
||||
authRequired: true,
|
||||
certificateChain: [
|
||||
expect.objectContaining({
|
||||
raw: 'Y2VydA==',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(tlsConnectMock).toHaveBeenCalledWith({
|
||||
host: 'localhost',
|
||||
port: 9200,
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails if peer certificate cannot be fetched', async () => {
|
||||
mockPingClient.asInternalUser.ping.mockRejectedValue(
|
||||
new errors.ResponseError(
|
||||
interactiveSetupMock.createApiResponse({ statusCode: 401, body: {} })
|
||||
)
|
||||
);
|
||||
|
||||
tlsConnectMock.mockReturnValue(({
|
||||
once: jest.fn((event, fn) => {
|
||||
if (event === 'error') {
|
||||
fn(new Error('some-message'));
|
||||
}
|
||||
}),
|
||||
} as unknown) as tls.TLSSocket);
|
||||
|
||||
await expect(setupContract.ping('https://localhost:9200')).rejects.toMatchInlineSnapshot(
|
||||
`[Error: some-message]`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#stop()', () => {
|
||||
|
@ -489,7 +625,7 @@ some weird+ca/with
|
|||
const mockConnectionStatusClient = elasticsearchServiceMock.createCustomClusterClient();
|
||||
mockElasticsearchPreboot.createClient.mockImplementation((type) => {
|
||||
switch (type) {
|
||||
case 'ping':
|
||||
case 'connectionStatus':
|
||||
return mockConnectionStatusClient;
|
||||
default:
|
||||
throw new Error(`Unexpected client type: ${type}`);
|
||||
|
|
|
@ -19,9 +19,9 @@ import {
|
|||
shareReplay,
|
||||
takeWhile,
|
||||
} from 'rxjs/operators';
|
||||
import tls from 'tls';
|
||||
|
||||
import type {
|
||||
ElasticsearchClientConfig,
|
||||
ElasticsearchServicePreboot,
|
||||
ICustomClusterClient,
|
||||
Logger,
|
||||
|
@ -29,14 +29,22 @@ import type {
|
|||
} from 'src/core/server';
|
||||
|
||||
import { ElasticsearchConnectionStatus } from '../common';
|
||||
import { getDetailedErrorMessage } from './errors';
|
||||
import type { Certificate, PingResult } from '../common';
|
||||
import { getDetailedErrorMessage, getErrorStatusCode } from './errors';
|
||||
|
||||
interface EnrollParameters {
|
||||
export interface EnrollParameters {
|
||||
apiKey: string;
|
||||
hosts: string[];
|
||||
caFingerprint: string;
|
||||
}
|
||||
|
||||
export interface AuthenticateParameters {
|
||||
host: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
caCert?: string;
|
||||
}
|
||||
|
||||
export interface ElasticsearchServiceSetupDeps {
|
||||
/**
|
||||
* Core Elasticsearch service preboot contract;
|
||||
|
@ -63,6 +71,16 @@ export interface ElasticsearchServiceSetup {
|
|||
* to point to exactly same Elasticsearch node, potentially available via different network interfaces.
|
||||
*/
|
||||
enroll: (params: EnrollParameters) => Promise<EnrollResult>;
|
||||
|
||||
/**
|
||||
* Tries to authenticate specified user with cluster.
|
||||
*/
|
||||
authenticate: (params: AuthenticateParameters) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Tries to connect to specified cluster and fetches certificate chain.
|
||||
*/
|
||||
ping: (host: string) => Promise<PingResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -76,13 +94,20 @@ export interface EnrollResult {
|
|||
/**
|
||||
* PEM CA certificate for the Elasticsearch HTTP certificates.
|
||||
*/
|
||||
ca: string;
|
||||
caCert: string;
|
||||
/**
|
||||
* Service account token for the "elastic/kibana" service account.
|
||||
*/
|
||||
serviceAccountToken: { name: string; value: string };
|
||||
}
|
||||
|
||||
export interface AuthenticateResult {
|
||||
host: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
caCert?: string;
|
||||
}
|
||||
|
||||
export class ElasticsearchService {
|
||||
/**
|
||||
* Elasticsearch client used to check Elasticsearch connection status.
|
||||
|
@ -95,7 +120,7 @@ export class ElasticsearchService {
|
|||
connectionCheckInterval,
|
||||
}: ElasticsearchServiceSetupDeps): ElasticsearchServiceSetup {
|
||||
const connectionStatusClient = (this.connectionStatusClient = elasticsearch.createClient(
|
||||
'ping'
|
||||
'connectionStatus'
|
||||
));
|
||||
|
||||
return {
|
||||
|
@ -120,6 +145,8 @@ export class ElasticsearchService {
|
|||
shareReplay({ refCount: true, bufferSize: 1 })
|
||||
),
|
||||
enroll: this.enroll.bind(this, elasticsearch),
|
||||
authenticate: this.authenticate.bind(this, elasticsearch),
|
||||
ping: this.ping.bind(this, elasticsearch),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -145,11 +172,8 @@ export class ElasticsearchService {
|
|||
private async enroll(
|
||||
elasticsearch: ElasticsearchServicePreboot,
|
||||
{ apiKey, hosts, caFingerprint }: EnrollParameters
|
||||
): Promise<EnrollResult> {
|
||||
) {
|
||||
const scopeableRequest: ScopeableRequest = { headers: { authorization: `ApiKey ${apiKey}` } };
|
||||
const elasticsearchConfig: Partial<ElasticsearchClientConfig> = {
|
||||
ssl: { verificationMode: 'none' },
|
||||
};
|
||||
|
||||
// We should iterate through all provided hosts until we find an accessible one.
|
||||
for (const host of hosts) {
|
||||
|
@ -158,9 +182,9 @@ export class ElasticsearchService {
|
|||
);
|
||||
|
||||
const enrollClient = elasticsearch.createClient('enroll', {
|
||||
...elasticsearchConfig,
|
||||
hosts: [host],
|
||||
caFingerprint,
|
||||
ssl: { verificationMode: 'none' },
|
||||
});
|
||||
|
||||
let enrollmentResponse;
|
||||
|
@ -176,51 +200,51 @@ export class ElasticsearchService {
|
|||
// that enrollment will fail for any other host and we should bail out.
|
||||
if (err instanceof errors.ConnectionError || err instanceof errors.TimeoutError) {
|
||||
this.logger.error(
|
||||
`Unable to connect to "${host}" host, will proceed to the next host if available: ${getDetailedErrorMessage(
|
||||
`Unable to connect to host "${host}", will proceed to the next host if available: ${getDetailedErrorMessage(
|
||||
err
|
||||
)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger.error(`Failed to enroll with "${host}" host: ${getDetailedErrorMessage(err)}`);
|
||||
this.logger.error(`Failed to enroll with host "${host}": ${getDetailedErrorMessage(err)}`);
|
||||
throw err;
|
||||
} finally {
|
||||
await enrollClient.close();
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Successfully enrolled with "${host}" host, token name: ${enrollmentResponse.body.token.name}, CA certificate: ${enrollmentResponse.body.http_ca}`
|
||||
`Successfully enrolled with host "${host}", token name: ${enrollmentResponse.body.token.name}, CA certificate: ${enrollmentResponse.body.http_ca}`
|
||||
);
|
||||
|
||||
const enrollResult = {
|
||||
const enrollResult: EnrollResult = {
|
||||
host,
|
||||
ca: ElasticsearchService.createPemCertificate(enrollmentResponse.body.http_ca),
|
||||
caCert: ElasticsearchService.createPemCertificate(enrollmentResponse.body.http_ca),
|
||||
serviceAccountToken: enrollmentResponse.body.token,
|
||||
};
|
||||
|
||||
// Now try to use retrieved password and CA certificate to authenticate to this host.
|
||||
// Now try to use retrieved service account and CA certificate to authenticate to this host.
|
||||
const authenticateClient = elasticsearch.createClient('authenticate', {
|
||||
caFingerprint,
|
||||
hosts: [host],
|
||||
serviceAccountToken: enrollResult.serviceAccountToken.value,
|
||||
ssl: { certificateAuthorities: [enrollResult.ca] },
|
||||
ssl: { certificateAuthorities: [enrollResult.caCert] },
|
||||
});
|
||||
|
||||
this.logger.debug(
|
||||
`Verifying if "${enrollmentResponse.body.token.name}" token can authenticate to "${host}" host.`
|
||||
`Verifying if "${enrollmentResponse.body.token.name}" token can authenticate to host "${host}".`
|
||||
);
|
||||
|
||||
try {
|
||||
await authenticateClient.asInternalUser.security.authenticate();
|
||||
this.logger.debug(
|
||||
`Successfully authenticated "${enrollmentResponse.body.token.name}" token to "${host}" host.`
|
||||
`Successfully authenticated "${enrollmentResponse.body.token.name}" token to host "${host}".`
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to authenticate "${
|
||||
enrollmentResponse.body.token.name
|
||||
}" token to "${host}" host: ${getDetailedErrorMessage(err)}.`
|
||||
}" token to host "${host}": ${getDetailedErrorMessage(err)}.`
|
||||
);
|
||||
throw err;
|
||||
} finally {
|
||||
|
@ -233,7 +257,114 @@ export class ElasticsearchService {
|
|||
throw new Error('Unable to connect to any of the provided hosts.');
|
||||
}
|
||||
|
||||
private static createPemCertificate(derCaString: string) {
|
||||
private async authenticate(
|
||||
elasticsearch: ElasticsearchServicePreboot,
|
||||
{ host, username, password, caCert }: AuthenticateParameters
|
||||
) {
|
||||
const client = elasticsearch.createClient('authenticate', {
|
||||
hosts: [host],
|
||||
username,
|
||||
password,
|
||||
ssl: caCert ? { certificateAuthorities: [caCert] } : undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
// Using `ping` instead of `authenticate` allows us to verify clusters with both
|
||||
// security enabled and disabled.
|
||||
await client.asInternalUser.ping();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to authenticate with host "${host}": ${getDetailedErrorMessage(error)}`
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async ping(elasticsearch: ElasticsearchServicePreboot, host: string) {
|
||||
const client = elasticsearch.createClient('ping', {
|
||||
hosts: [host],
|
||||
username: '',
|
||||
password: '',
|
||||
ssl: { verificationMode: 'none' },
|
||||
});
|
||||
|
||||
let authRequired = false;
|
||||
try {
|
||||
await client.asInternalUser.ping();
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof errors.ConnectionError ||
|
||||
error instanceof errors.TimeoutError ||
|
||||
error instanceof errors.ProductNotSupportedError
|
||||
) {
|
||||
this.logger.error(`Unable to connect to host "${host}": ${getDetailedErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
authRequired = getErrorStatusCode(error) === 401;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
|
||||
let certificateChain: Certificate[] | undefined;
|
||||
const { protocol, hostname, port } = new URL(host);
|
||||
if (protocol === 'https:') {
|
||||
try {
|
||||
const cert = await ElasticsearchService.fetchPeerCertificate(hostname, port);
|
||||
certificateChain = ElasticsearchService.flattenCertificateChain(cert).map(
|
||||
ElasticsearchService.getCertificate
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to fetch peer certificate from host "${host}": ${getDetailedErrorMessage(error)}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authRequired,
|
||||
certificateChain,
|
||||
};
|
||||
}
|
||||
|
||||
private static fetchPeerCertificate(host: string, port: string | number) {
|
||||
return new Promise<tls.DetailedPeerCertificate>((resolve, reject) => {
|
||||
const socket = tls.connect({ host, port: Number(port), rejectUnauthorized: false });
|
||||
socket.once('secureConnect', () => {
|
||||
const cert = socket.getPeerCertificate(true);
|
||||
socket.destroy();
|
||||
resolve(cert);
|
||||
});
|
||||
socket.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
private static flattenCertificateChain(
|
||||
cert: tls.DetailedPeerCertificate,
|
||||
accumulator: tls.DetailedPeerCertificate[] = []
|
||||
) {
|
||||
accumulator.push(cert);
|
||||
if (cert.issuerCertificate && cert.fingerprint256 !== cert.issuerCertificate.fingerprint256) {
|
||||
ElasticsearchService.flattenCertificateChain(cert.issuerCertificate, accumulator);
|
||||
}
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
private static getCertificate(cert: tls.DetailedPeerCertificate): Certificate {
|
||||
return {
|
||||
issuer: cert.issuer,
|
||||
valid_from: cert.valid_from,
|
||||
valid_to: cert.valid_to,
|
||||
subject: cert.subject,
|
||||
fingerprint256: cert.fingerprint256,
|
||||
raw: cert.raw.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
public static createPemCertificate(derCaString: string) {
|
||||
// Use `X509Certificate` class once we upgrade to Node v16.
|
||||
return `-----BEGIN CERTIFICATE-----\n${derCaString
|
||||
.replace(/_/g, '/')
|
||||
|
|
|
@ -14,7 +14,7 @@ import type {
|
|||
} from 'src/core/server';
|
||||
|
||||
import { ConfigSchema } from './config';
|
||||
import { UserSetupPlugin } from './plugin';
|
||||
import { InteractiveSetupPlugin } from './plugin';
|
||||
|
||||
export const config: PluginConfigDescriptor<TypeOf<typeof ConfigSchema>> = {
|
||||
schema: ConfigSchema,
|
||||
|
@ -22,4 +22,4 @@ export const config: PluginConfigDescriptor<TypeOf<typeof ConfigSchema>> = {
|
|||
|
||||
export const plugin: PluginInitializer<void, never> = (
|
||||
initializerContext: PluginInitializerContext
|
||||
) => new UserSetupPlugin(initializerContext);
|
||||
) => new InteractiveSetupPlugin(initializerContext);
|
||||
|
|
|
@ -74,7 +74,7 @@ describe('KibanaConfigWriter', () => {
|
|||
|
||||
await expect(
|
||||
kibanaConfigWriter.writeConfig({
|
||||
ca: 'ca-content',
|
||||
caCert: 'ca-content',
|
||||
host: '',
|
||||
serviceAccountToken: { name: '', value: '' },
|
||||
})
|
||||
|
@ -90,7 +90,7 @@ describe('KibanaConfigWriter', () => {
|
|||
|
||||
await expect(
|
||||
kibanaConfigWriter.writeConfig({
|
||||
ca: 'ca-content',
|
||||
caCert: 'ca-content',
|
||||
host: 'some-host',
|
||||
serviceAccountToken: { name: 'some-token', value: 'some-value' },
|
||||
})
|
||||
|
@ -103,7 +103,7 @@ describe('KibanaConfigWriter', () => {
|
|||
'/some/path/kibana.yml',
|
||||
`
|
||||
|
||||
# This section was automatically generated during setup (service account token name is "some-token").
|
||||
# This section was automatically generated during setup.
|
||||
elasticsearch.hosts: [some-host]
|
||||
elasticsearch.serviceAccountToken: some-value
|
||||
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
|
||||
|
@ -112,10 +112,10 @@ elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
|
|||
);
|
||||
});
|
||||
|
||||
it('can successfully write CA certificate and elasticsearch config to the disk', async () => {
|
||||
it('can successfully write CA certificate and elasticsearch config with service token', async () => {
|
||||
await expect(
|
||||
kibanaConfigWriter.writeConfig({
|
||||
ca: 'ca-content',
|
||||
caCert: 'ca-content',
|
||||
host: 'some-host',
|
||||
serviceAccountToken: { name: 'some-token', value: 'some-value' },
|
||||
})
|
||||
|
@ -128,11 +128,62 @@ elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
|
|||
'/some/path/kibana.yml',
|
||||
`
|
||||
|
||||
# This section was automatically generated during setup (service account token name is "some-token").
|
||||
# This section was automatically generated during setup.
|
||||
elasticsearch.hosts: [some-host]
|
||||
elasticsearch.serviceAccountToken: some-value
|
||||
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
|
||||
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('can successfully write CA certificate and elasticsearch config with credentials', async () => {
|
||||
await expect(
|
||||
kibanaConfigWriter.writeConfig({
|
||||
caCert: 'ca-content',
|
||||
host: 'some-host',
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mockWriteFile).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content');
|
||||
expect(mockAppendFile).toHaveBeenCalledTimes(1);
|
||||
expect(mockAppendFile).toHaveBeenCalledWith(
|
||||
'/some/path/kibana.yml',
|
||||
`
|
||||
|
||||
# This section was automatically generated during setup.
|
||||
elasticsearch.hosts: [some-host]
|
||||
elasticsearch.password: password
|
||||
elasticsearch.username: username
|
||||
elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt]
|
||||
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('can successfully write elasticsearch config without CA certificate', async () => {
|
||||
await expect(
|
||||
kibanaConfigWriter.writeConfig({
|
||||
host: 'some-host',
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
expect(mockAppendFile).toHaveBeenCalledTimes(1);
|
||||
expect(mockAppendFile).toHaveBeenCalledWith(
|
||||
'/some/path/kibana.yml',
|
||||
`
|
||||
|
||||
# This section was automatically generated during setup.
|
||||
elasticsearch.hosts: [some-host]
|
||||
elasticsearch.password: password
|
||||
elasticsearch.username: username
|
||||
|
||||
`
|
||||
);
|
||||
});
|
||||
|
|
|
@ -15,11 +15,19 @@ import type { Logger } from 'src/core/server';
|
|||
|
||||
import { getDetailedErrorMessage } from './errors';
|
||||
|
||||
export interface WriteConfigParameters {
|
||||
export type WriteConfigParameters = {
|
||||
host: string;
|
||||
ca: string;
|
||||
serviceAccountToken: { name: string; value: string };
|
||||
}
|
||||
caCert?: string;
|
||||
} & (
|
||||
| {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
| {
|
||||
serviceAccountToken: { name: string; value: string };
|
||||
}
|
||||
| {}
|
||||
);
|
||||
|
||||
export class KibanaConfigWriter {
|
||||
constructor(private readonly configPath: string, private readonly logger: Logger) {}
|
||||
|
@ -54,31 +62,37 @@ export class KibanaConfigWriter {
|
|||
public async writeConfig(params: WriteConfigParameters) {
|
||||
const caPath = path.join(path.dirname(this.configPath), `ca_${Date.now()}.crt`);
|
||||
|
||||
this.logger.debug(`Writing CA certificate to ${caPath}.`);
|
||||
try {
|
||||
await fs.writeFile(caPath, params.ca);
|
||||
this.logger.debug(`Successfully wrote CA certificate to ${caPath}.`);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to write CA certificate to ${caPath}: ${getDetailedErrorMessage(err)}.`
|
||||
);
|
||||
throw err;
|
||||
if (params.caCert) {
|
||||
this.logger.debug(`Writing CA certificate to ${caPath}.`);
|
||||
try {
|
||||
await fs.writeFile(caPath, params.caCert);
|
||||
this.logger.debug(`Successfully wrote CA certificate to ${caPath}.`);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to write CA certificate to ${caPath}: ${getDetailedErrorMessage(err)}.`
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const config: Record<string, any> = { 'elasticsearch.hosts': [params.host] };
|
||||
if ('serviceAccountToken' in params) {
|
||||
config['elasticsearch.serviceAccountToken'] = params.serviceAccountToken.value;
|
||||
} else if ('username' in params) {
|
||||
config['elasticsearch.password'] = params.password;
|
||||
config['elasticsearch.username'] = params.username;
|
||||
}
|
||||
if (params.caCert) {
|
||||
config['elasticsearch.ssl.certificateAuthorities'] = [caPath];
|
||||
}
|
||||
|
||||
this.logger.debug(`Writing Elasticsearch configuration to ${this.configPath}.`);
|
||||
try {
|
||||
await fs.appendFile(
|
||||
this.configPath,
|
||||
`\n\n# This section was automatically generated during setup (service account token name is "${
|
||||
params.serviceAccountToken.name
|
||||
}").\n${yaml.safeDump(
|
||||
{
|
||||
'elasticsearch.hosts': [params.host],
|
||||
'elasticsearch.serviceAccountToken': params.serviceAccountToken.value,
|
||||
'elasticsearch.ssl.certificateAuthorities': [caPath],
|
||||
},
|
||||
{ flowLevel: 1 }
|
||||
)}\n`
|
||||
`\n\n# This section was automatically generated during setup.\n${yaml.safeDump(config, {
|
||||
flowLevel: 1,
|
||||
})}\n`
|
||||
);
|
||||
this.logger.debug(`Successfully wrote Elasticsearch configuration to ${this.configPath}.`);
|
||||
} catch (err) {
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import type { Subscription } from 'rxjs';
|
||||
|
||||
import type { TypeOf } from '@kbn/config-schema';
|
||||
|
@ -17,13 +18,11 @@ import { ElasticsearchService } from './elasticsearch_service';
|
|||
import { KibanaConfigWriter } from './kibana_config_writer';
|
||||
import { defineRoutes } from './routes';
|
||||
|
||||
export class UserSetupPlugin implements PrebootPlugin {
|
||||
export class InteractiveSetupPlugin implements PrebootPlugin {
|
||||
readonly #logger: Logger;
|
||||
readonly #elasticsearch: ElasticsearchService;
|
||||
|
||||
#elasticsearchConnectionStatusSubscription?: Subscription;
|
||||
readonly #elasticsearch = new ElasticsearchService(
|
||||
this.initializerContext.logger.get('elasticsearch')
|
||||
);
|
||||
|
||||
#configSubscription?: Subscription;
|
||||
#config?: ConfigType;
|
||||
|
@ -36,6 +35,9 @@ export class UserSetupPlugin implements PrebootPlugin {
|
|||
|
||||
constructor(private readonly initializerContext: PluginInitializerContext) {
|
||||
this.#logger = this.initializerContext.logger.get();
|
||||
this.#elasticsearch = new ElasticsearchService(
|
||||
this.initializerContext.logger.get('elasticsearch')
|
||||
);
|
||||
}
|
||||
|
||||
public setup(core: CorePreboot) {
|
||||
|
@ -90,6 +92,14 @@ export class UserSetupPlugin implements PrebootPlugin {
|
|||
this.#logger.debug(
|
||||
'Starting interactive setup mode since Kibana cannot to connect to Elasticsearch at http://localhost:9200.'
|
||||
);
|
||||
const serverInfo = core.http.getServerInfo();
|
||||
const url = `${serverInfo.protocol}://${serverInfo.hostname}:${serverInfo.port}`;
|
||||
this.#logger.info(`
|
||||
|
||||
${chalk.whiteBright.bold(`${chalk.cyanBright('i')} Kibana has not been configured.`)}
|
||||
|
||||
Go to ${chalk.cyanBright.underline(url)} to get started.
|
||||
`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
276
src/plugins/interactive_setup/server/routes/configure.test.ts
Normal file
276
src/plugins/interactive_setup/server/routes/configure.test.ts
Normal file
|
@ -0,0 +1,276 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { errors } from '@elastic/elasticsearch';
|
||||
|
||||
import type { ObjectType } from '@kbn/config-schema';
|
||||
import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from 'src/core/server';
|
||||
import { kibanaResponseFactory } from 'src/core/server';
|
||||
import { httpServerMock } from 'src/core/server/mocks';
|
||||
|
||||
import { ElasticsearchConnectionStatus } from '../../common';
|
||||
import { interactiveSetupMock } from '../mocks';
|
||||
import { defineConfigureRoute } from './configure';
|
||||
import { routeDefinitionParamsMock } from './index.mock';
|
||||
|
||||
describe('Configure routes', () => {
|
||||
let router: jest.Mocked<IRouter>;
|
||||
let mockRouteParams: ReturnType<typeof routeDefinitionParamsMock.create>;
|
||||
let mockContext: RequestHandlerContext;
|
||||
beforeEach(() => {
|
||||
mockRouteParams = routeDefinitionParamsMock.create();
|
||||
router = mockRouteParams.router;
|
||||
|
||||
mockContext = ({} as unknown) as RequestHandlerContext;
|
||||
|
||||
defineConfigureRoute(mockRouteParams);
|
||||
});
|
||||
|
||||
describe('#configure', () => {
|
||||
let routeHandler: RequestHandler<any, any, any>;
|
||||
let routeConfig: RouteConfig<any, any, any, any>;
|
||||
|
||||
beforeEach(() => {
|
||||
const [configureRouteConfig, configureRouteHandler] = router.post.mock.calls.find(
|
||||
([{ path }]) => path === '/internal/interactive_setup/configure'
|
||||
)!;
|
||||
|
||||
routeConfig = configureRouteConfig;
|
||||
routeHandler = configureRouteHandler;
|
||||
});
|
||||
|
||||
it('correctly defines route.', () => {
|
||||
expect(routeConfig.options).toEqual({ authRequired: false });
|
||||
|
||||
const bodySchema = (routeConfig.validate as any).body as ObjectType;
|
||||
expect(() => bodySchema.validate({})).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[host]: expected value of type [string] but got [undefined]."`
|
||||
);
|
||||
expect(() => bodySchema.validate({ host: '' })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[host]: \\"host\\" is not allowed to be empty"`
|
||||
);
|
||||
expect(() =>
|
||||
bodySchema.validate({ host: 'localhost:9200' })
|
||||
).toThrowErrorMatchingInlineSnapshot(`"[host]: expected URI with scheme [http|https]."`);
|
||||
expect(() => bodySchema.validate({ host: 'http://localhost:9200' })).not.toThrowError();
|
||||
expect(() =>
|
||||
bodySchema.validate({ host: 'http://localhost:9200', username: 'elastic' })
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[username]: value of \\"elastic\\" is forbidden. This is a superuser account that can obfuscate privilege-related issues. You should use the \\"kibana_system\\" user instead."`
|
||||
);
|
||||
expect(() =>
|
||||
bodySchema.validate({ host: 'http://localhost:9200', username: 'kibana_system' })
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[password]: expected value of type [string] but got [undefined]"`
|
||||
);
|
||||
expect(() =>
|
||||
bodySchema.validate({ host: 'http://localhost:9200', password: 'password' })
|
||||
).toThrowErrorMatchingInlineSnapshot(`"[password]: a value wasn't expected to be present"`);
|
||||
expect(() =>
|
||||
bodySchema.validate({
|
||||
host: 'http://localhost:9200',
|
||||
username: 'kibana_system',
|
||||
password: '',
|
||||
})
|
||||
).not.toThrowError();
|
||||
expect(() =>
|
||||
bodySchema.validate({ host: 'https://localhost:9200' })
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[caCert]: expected value of type [string] but got [undefined]"`
|
||||
);
|
||||
expect(() =>
|
||||
bodySchema.validate({ host: 'https://localhost:9200', caCert: 'der' })
|
||||
).not.toThrowError();
|
||||
});
|
||||
|
||||
it('fails if setup is not on hold.', async () => {
|
||||
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(false);
|
||||
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
body: { host: 'host1' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 400,
|
||||
options: { body: 'Cannot process request outside of preboot stage.' },
|
||||
payload: 'Cannot process request outside of preboot stage.',
|
||||
});
|
||||
|
||||
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
|
||||
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails if Elasticsearch connection is already configured.', async () => {
|
||||
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
|
||||
mockRouteParams.elasticsearch.connectionStatus$.next(
|
||||
ElasticsearchConnectionStatus.Configured
|
||||
);
|
||||
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
body: { host: 'host1' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 400,
|
||||
options: {
|
||||
body: {
|
||||
message: 'Elasticsearch connection is already configured.',
|
||||
attributes: { type: 'elasticsearch_connection_configured' },
|
||||
},
|
||||
},
|
||||
payload: {
|
||||
message: 'Elasticsearch connection is already configured.',
|
||||
attributes: { type: 'elasticsearch_connection_configured' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
|
||||
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails if Kibana config is not writable.', async () => {
|
||||
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
|
||||
mockRouteParams.elasticsearch.connectionStatus$.next(
|
||||
ElasticsearchConnectionStatus.NotConfigured
|
||||
);
|
||||
mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(false);
|
||||
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
body: { host: 'host1' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 500,
|
||||
options: {
|
||||
body: {
|
||||
message: 'Kibana process does not have enough permissions to write to config file.',
|
||||
attributes: { type: 'kibana_config_not_writable' },
|
||||
},
|
||||
statusCode: 500,
|
||||
},
|
||||
payload: {
|
||||
message: 'Kibana process does not have enough permissions to write to config file.',
|
||||
attributes: { type: 'kibana_config_not_writable' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
|
||||
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails if authenticate call fails.', async () => {
|
||||
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
|
||||
mockRouteParams.elasticsearch.connectionStatus$.next(
|
||||
ElasticsearchConnectionStatus.NotConfigured
|
||||
);
|
||||
mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(true);
|
||||
mockRouteParams.elasticsearch.authenticate.mockRejectedValue(
|
||||
new errors.ResponseError(
|
||||
interactiveSetupMock.createApiResponse({
|
||||
statusCode: 401,
|
||||
body: { message: 'some-secret-message' },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
body: { host: 'host1' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 500,
|
||||
options: {
|
||||
body: { message: 'Failed to configure.', attributes: { type: 'configure_failure' } },
|
||||
statusCode: 500,
|
||||
},
|
||||
payload: { message: 'Failed to configure.', attributes: { type: 'configure_failure' } },
|
||||
});
|
||||
|
||||
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
|
||||
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails if cannot write configuration to the disk.', async () => {
|
||||
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
|
||||
mockRouteParams.elasticsearch.connectionStatus$.next(
|
||||
ElasticsearchConnectionStatus.NotConfigured
|
||||
);
|
||||
mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(true);
|
||||
mockRouteParams.kibanaConfigWriter.writeConfig.mockRejectedValue(
|
||||
new Error('Some error with sensitive path')
|
||||
);
|
||||
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
body: { host: 'host1' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 500,
|
||||
options: {
|
||||
body: {
|
||||
message: 'Failed to save configuration.',
|
||||
attributes: { type: 'kibana_config_failure' },
|
||||
},
|
||||
statusCode: 500,
|
||||
},
|
||||
payload: {
|
||||
message: 'Failed to save configuration.',
|
||||
attributes: { type: 'kibana_config_failure' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can successfully authenticate and save configuration to the disk.', async () => {
|
||||
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
|
||||
mockRouteParams.elasticsearch.connectionStatus$.next(
|
||||
ElasticsearchConnectionStatus.NotConfigured
|
||||
);
|
||||
mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(true);
|
||||
mockRouteParams.kibanaConfigWriter.writeConfig.mockResolvedValue();
|
||||
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
body: { host: 'host', username: 'username', password: 'password', caCert: 'der' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 204,
|
||||
options: {},
|
||||
payload: undefined,
|
||||
});
|
||||
|
||||
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledWith({
|
||||
host: 'host',
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
caCert: '-----BEGIN CERTIFICATE-----\nder\n-----END CERTIFICATE-----\n',
|
||||
});
|
||||
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledWith({
|
||||
host: 'host',
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
caCert: '-----BEGIN CERTIFICATE-----\nder\n-----END CERTIFICATE-----\n',
|
||||
});
|
||||
|
||||
expect(mockRouteParams.preboot.completeSetup).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.preboot.completeSetup).toHaveBeenCalledWith({
|
||||
shouldReloadConfig: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
136
src/plugins/interactive_setup/server/routes/configure.ts
Normal file
136
src/plugins/interactive_setup/server/routes/configure.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { first } from 'rxjs/operators';
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import type { RouteDefinitionParams } from '.';
|
||||
import { ElasticsearchConnectionStatus } from '../../common';
|
||||
import type { AuthenticateParameters } from '../elasticsearch_service';
|
||||
import { ElasticsearchService } from '../elasticsearch_service';
|
||||
import type { WriteConfigParameters } from '../kibana_config_writer';
|
||||
|
||||
export function defineConfigureRoute({
|
||||
router,
|
||||
logger,
|
||||
kibanaConfigWriter,
|
||||
elasticsearch,
|
||||
preboot,
|
||||
}: RouteDefinitionParams) {
|
||||
router.post(
|
||||
{
|
||||
path: '/internal/interactive_setup/configure',
|
||||
validate: {
|
||||
query: schema.object({
|
||||
code: schema.maybe(schema.string()),
|
||||
}),
|
||||
body: schema.object({
|
||||
host: schema.uri({ scheme: ['http', 'https'] }),
|
||||
username: schema.maybe(
|
||||
schema.string({
|
||||
validate: (value: string) => {
|
||||
if (value === 'elastic') {
|
||||
return (
|
||||
'value of "elastic" is forbidden. This is a superuser account that can obfuscate ' +
|
||||
'privilege-related issues. You should use the "kibana_system" user instead.'
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
),
|
||||
password: schema.conditional(
|
||||
schema.siblingRef('username'),
|
||||
schema.string(),
|
||||
schema.string(),
|
||||
schema.never()
|
||||
),
|
||||
caCert: schema.conditional(
|
||||
schema.siblingRef('host'),
|
||||
schema.uri({ scheme: 'https' }),
|
||||
schema.string(),
|
||||
schema.never()
|
||||
),
|
||||
}),
|
||||
},
|
||||
options: { authRequired: false },
|
||||
},
|
||||
async (context, request, response) => {
|
||||
if (!preboot.isSetupOnHold()) {
|
||||
logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`);
|
||||
return response.badRequest({ body: 'Cannot process request outside of preboot stage.' });
|
||||
}
|
||||
|
||||
const connectionStatus = await elasticsearch.connectionStatus$.pipe(first()).toPromise();
|
||||
if (connectionStatus === ElasticsearchConnectionStatus.Configured) {
|
||||
logger.error(
|
||||
`Invalid request to [path=${request.url.pathname}], Elasticsearch connection is already configured.`
|
||||
);
|
||||
return response.badRequest({
|
||||
body: {
|
||||
message: 'Elasticsearch connection is already configured.',
|
||||
attributes: { type: 'elasticsearch_connection_configured' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// The most probable misconfiguration case is when Kibana process isn't allowed to write to the
|
||||
// Kibana configuration file. We'll still have to handle possible filesystem access errors
|
||||
// when we actually write to the disk, but this preliminary check helps us to avoid unnecessary
|
||||
// enrollment call and communicate that to the user early.
|
||||
const isConfigWritable = await kibanaConfigWriter.isConfigWritable();
|
||||
if (!isConfigWritable) {
|
||||
logger.error('Kibana process does not have enough permissions to write to config file');
|
||||
return response.customError({
|
||||
statusCode: 500,
|
||||
body: {
|
||||
message: 'Kibana process does not have enough permissions to write to config file.',
|
||||
attributes: { type: 'kibana_config_not_writable' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const configToWrite: WriteConfigParameters & AuthenticateParameters = {
|
||||
host: request.body.host,
|
||||
username: request.body.username,
|
||||
password: request.body.password,
|
||||
caCert: request.body.caCert
|
||||
? ElasticsearchService.createPemCertificate(request.body.caCert)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
await elasticsearch.authenticate(configToWrite);
|
||||
} catch {
|
||||
// For security reasons, we shouldn't leak to the user whether Elasticsearch node couldn't process enrollment
|
||||
// request or we just couldn't connect to any of the provided hosts.
|
||||
return response.customError({
|
||||
statusCode: 500,
|
||||
body: { message: 'Failed to configure.', attributes: { type: 'configure_failure' } },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await kibanaConfigWriter.writeConfig(configToWrite);
|
||||
} catch {
|
||||
// For security reasons, we shouldn't leak any filesystem related errors.
|
||||
return response.customError({
|
||||
statusCode: 500,
|
||||
body: {
|
||||
message: 'Failed to save configuration.',
|
||||
attributes: { type: 'kibana_config_failure' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
preboot.completeSetup({ shouldReloadConfig: true });
|
||||
|
||||
return response.noContent();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -12,6 +12,7 @@ import { schema } from '@kbn/config-schema';
|
|||
|
||||
import { ElasticsearchConnectionStatus } from '../../common';
|
||||
import type { EnrollResult } from '../elasticsearch_service';
|
||||
import type { WriteConfigParameters } from '../kibana_config_writer';
|
||||
import type { RouteDefinitionParams } from './';
|
||||
|
||||
/**
|
||||
|
@ -81,9 +82,9 @@ export function defineEnrollRoutes({
|
|||
.match(/.{1,2}/g)
|
||||
?.join(':') ?? '';
|
||||
|
||||
let enrollResult: EnrollResult;
|
||||
let configToWrite: WriteConfigParameters & EnrollResult;
|
||||
try {
|
||||
enrollResult = await elasticsearch.enroll({
|
||||
configToWrite = await elasticsearch.enroll({
|
||||
apiKey: request.body.apiKey,
|
||||
hosts: request.body.hosts,
|
||||
caFingerprint: colonFormattedCaFingerprint,
|
||||
|
@ -98,7 +99,7 @@ export function defineEnrollRoutes({
|
|||
}
|
||||
|
||||
try {
|
||||
await kibanaConfigWriter.writeConfig(enrollResult);
|
||||
await kibanaConfigWriter.writeConfig(configToWrite);
|
||||
} catch {
|
||||
// For security reasons, we shouldn't leak any filesystem related errors.
|
||||
return response.customError({
|
||||
|
|
|
@ -12,7 +12,9 @@ import type { IBasePath, IRouter, Logger, PrebootServicePreboot } from 'src/core
|
|||
import type { ConfigType } from '../config';
|
||||
import type { ElasticsearchServiceSetup } from '../elasticsearch_service';
|
||||
import type { KibanaConfigWriter } from '../kibana_config_writer';
|
||||
import { defineConfigureRoute } from './configure';
|
||||
import { defineEnrollRoutes } from './enroll';
|
||||
import { definePingRoute } from './ping';
|
||||
|
||||
/**
|
||||
* Describes parameters used to define HTTP routes.
|
||||
|
@ -31,4 +33,6 @@ export interface RouteDefinitionParams {
|
|||
|
||||
export function defineRoutes(params: RouteDefinitionParams) {
|
||||
defineEnrollRoutes(params);
|
||||
defineConfigureRoute(params);
|
||||
definePingRoute(params);
|
||||
}
|
||||
|
|
125
src/plugins/interactive_setup/server/routes/ping.test.ts
Normal file
125
src/plugins/interactive_setup/server/routes/ping.test.ts
Normal file
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { errors } from '@elastic/elasticsearch';
|
||||
|
||||
import type { ObjectType } from '@kbn/config-schema';
|
||||
import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from 'src/core/server';
|
||||
import { kibanaResponseFactory } from 'src/core/server';
|
||||
import { httpServerMock } from 'src/core/server/mocks';
|
||||
|
||||
import { interactiveSetupMock } from '../mocks';
|
||||
import { routeDefinitionParamsMock } from './index.mock';
|
||||
import { definePingRoute } from './ping';
|
||||
|
||||
describe('Configure routes', () => {
|
||||
let router: jest.Mocked<IRouter>;
|
||||
let mockRouteParams: ReturnType<typeof routeDefinitionParamsMock.create>;
|
||||
let mockContext: RequestHandlerContext;
|
||||
beforeEach(() => {
|
||||
mockRouteParams = routeDefinitionParamsMock.create();
|
||||
router = mockRouteParams.router;
|
||||
|
||||
mockContext = ({} as unknown) as RequestHandlerContext;
|
||||
|
||||
definePingRoute(mockRouteParams);
|
||||
});
|
||||
|
||||
describe('#ping', () => {
|
||||
let routeHandler: RequestHandler<any, any, any>;
|
||||
let routeConfig: RouteConfig<any, any, any, any>;
|
||||
|
||||
beforeEach(() => {
|
||||
const [configureRouteConfig, configureRouteHandler] = router.post.mock.calls.find(
|
||||
([{ path }]) => path === '/internal/interactive_setup/ping'
|
||||
)!;
|
||||
|
||||
routeConfig = configureRouteConfig;
|
||||
routeHandler = configureRouteHandler;
|
||||
});
|
||||
|
||||
it('correctly defines route.', () => {
|
||||
expect(routeConfig.options).toEqual({ authRequired: false });
|
||||
|
||||
const bodySchema = (routeConfig.validate as any).body as ObjectType;
|
||||
expect(() => bodySchema.validate({})).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[host]: expected value of type [string] but got [undefined]."`
|
||||
);
|
||||
expect(() => bodySchema.validate({ host: '' })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[host]: \\"host\\" is not allowed to be empty"`
|
||||
);
|
||||
expect(() =>
|
||||
bodySchema.validate({ host: 'localhost:9200' })
|
||||
).toThrowErrorMatchingInlineSnapshot(`"[host]: expected URI with scheme [http|https]."`);
|
||||
expect(() => bodySchema.validate({ host: 'http://localhost:9200' })).not.toThrowError();
|
||||
});
|
||||
|
||||
it('fails if setup is not on hold.', async () => {
|
||||
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(false);
|
||||
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
body: { host: 'host' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 400,
|
||||
options: { body: 'Cannot process request outside of preboot stage.' },
|
||||
payload: 'Cannot process request outside of preboot stage.',
|
||||
});
|
||||
|
||||
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
|
||||
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails if ping call fails.', async () => {
|
||||
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
|
||||
mockRouteParams.elasticsearch.ping.mockRejectedValue(
|
||||
new errors.ResponseError(
|
||||
interactiveSetupMock.createApiResponse({
|
||||
statusCode: 401,
|
||||
body: { message: 'some-secret-message' },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
body: { host: 'host' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 500,
|
||||
options: {
|
||||
body: { message: 'Failed to ping cluster.', attributes: { type: 'ping_failure' } },
|
||||
statusCode: 500,
|
||||
},
|
||||
payload: { message: 'Failed to ping cluster.', attributes: { type: 'ping_failure' } },
|
||||
});
|
||||
|
||||
expect(mockRouteParams.elasticsearch.ping).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can successfully ping.', async () => {
|
||||
mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true);
|
||||
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
body: { host: 'host' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 200,
|
||||
options: {},
|
||||
payload: undefined,
|
||||
});
|
||||
|
||||
expect(mockRouteParams.elasticsearch.ping).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.elasticsearch.ping).toHaveBeenCalledWith('host');
|
||||
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
44
src/plugins/interactive_setup/server/routes/ping.ts
Normal file
44
src/plugins/interactive_setup/server/routes/ping.ts
Normal 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import type { RouteDefinitionParams } from '.';
|
||||
import type { PingResult } from '../../common/types';
|
||||
|
||||
export function definePingRoute({ router, logger, elasticsearch, preboot }: RouteDefinitionParams) {
|
||||
router.post(
|
||||
{
|
||||
path: '/internal/interactive_setup/ping',
|
||||
validate: {
|
||||
body: schema.object({
|
||||
host: schema.uri({ scheme: ['http', 'https'] }),
|
||||
}),
|
||||
},
|
||||
options: { authRequired: false },
|
||||
},
|
||||
async (context, request, response) => {
|
||||
if (!preboot.isSetupOnHold()) {
|
||||
logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`);
|
||||
return response.badRequest({ body: 'Cannot process request outside of preboot stage.' });
|
||||
}
|
||||
|
||||
let result: PingResult;
|
||||
try {
|
||||
result = await elasticsearch.ping(request.body.host);
|
||||
} catch {
|
||||
return response.customError({
|
||||
statusCode: 500,
|
||||
body: { message: 'Failed to ping cluster.', attributes: { type: 'ping_failure' } },
|
||||
});
|
||||
}
|
||||
|
||||
return response.ok({ body: result });
|
||||
}
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue