[Snapshot & Restore] Decode URIs and fix editing of a policy (#76278) (#76322)

* fix URI decoding and editing of a policy which backs up all indices

* fix type issue

* fix general use of encoding and update decode algo

* fix serialisation of snapshots and added a test

* Fix test description name

* Update attempt_to_uri_decode.ts

* catch errors from decoding in the already throwing code

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Jean-Louis Leysens 2020-09-01 08:34:36 +02:00 committed by GitHub
parent db735b3433
commit f583c4e734
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 277 additions and 163 deletions

View file

@ -4,55 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { deserializeSnapshotDetails } from './snapshot_serialization';
import { deserializeSnapshotDetails, serializeSnapshotConfig } from './snapshot_serialization';
describe('deserializeSnapshotDetails', () => {
test('deserializes a snapshot', () => {
expect(
deserializeSnapshotDetails(
'repositoryName',
{
snapshot: 'snapshot name',
uuid: 'UUID',
version_id: 5,
version: 'version',
indices: ['index2', 'index3', 'index1'],
include_global_state: false,
state: 'SUCCESS',
start_time: '0',
start_time_in_millis: 0,
end_time: '1',
end_time_in_millis: 1,
duration_in_millis: 1,
shards: {
total: 3,
failed: 1,
successful: 2,
},
failures: [
{
index: 'z',
shard: 1,
},
{
index: 'a',
shard: 3,
},
{
index: 'a',
shard: 1,
},
{
index: 'a',
shard: 2,
},
],
},
'found-snapshots',
[
describe('Snapshot serialization and deserialization', () => {
describe('deserializeSnapshotDetails', () => {
test('deserializes a snapshot', () => {
expect(
deserializeSnapshotDetails(
'repositoryName',
{
snapshot: 'last_successful_snapshot',
uuid: 'last_successful_snapshot_UUID',
snapshot: 'snapshot name',
uuid: 'UUID',
version_id: 5,
version: 'version',
indices: ['index2', 'index3', 'index1'],
@ -87,56 +49,109 @@ describe('deserializeSnapshotDetails', () => {
},
],
},
]
)
).toEqual({
repository: 'repositoryName',
snapshot: 'snapshot name',
uuid: 'UUID',
versionId: 5,
version: 'version',
// Indices are sorted.
indices: ['index1', 'index2', 'index3'],
dataStreams: [],
includeGlobalState: false,
// Failures are grouped and sorted by index, and the failures themselves are sorted by shard.
indexFailures: [
{
index: 'a',
failures: [
'found-snapshots',
[
{
shard: 1,
snapshot: 'last_successful_snapshot',
uuid: 'last_successful_snapshot_UUID',
version_id: 5,
version: 'version',
indices: ['index2', 'index3', 'index1'],
include_global_state: false,
state: 'SUCCESS',
start_time: '0',
start_time_in_millis: 0,
end_time: '1',
end_time_in_millis: 1,
duration_in_millis: 1,
shards: {
total: 3,
failed: 1,
successful: 2,
},
failures: [
{
index: 'z',
shard: 1,
},
{
index: 'a',
shard: 3,
},
{
index: 'a',
shard: 1,
},
{
index: 'a',
shard: 2,
},
],
},
{
shard: 2,
},
{
shard: 3,
},
],
]
)
).toEqual({
repository: 'repositoryName',
snapshot: 'snapshot name',
uuid: 'UUID',
versionId: 5,
version: 'version',
// Indices are sorted.
indices: ['index1', 'index2', 'index3'],
dataStreams: [],
includeGlobalState: false,
// Failures are grouped and sorted by index, and the failures themselves are sorted by shard.
indexFailures: [
{
index: 'a',
failures: [
{
shard: 1,
},
{
shard: 2,
},
{
shard: 3,
},
],
},
{
index: 'z',
failures: [
{
shard: 1,
},
],
},
],
state: 'SUCCESS',
startTime: '0',
startTimeInMillis: 0,
endTime: '1',
endTimeInMillis: 1,
durationInMillis: 1,
shards: {
total: 3,
failed: 1,
successful: 2,
},
{
index: 'z',
failures: [
{
shard: 1,
},
],
},
],
state: 'SUCCESS',
startTime: '0',
startTimeInMillis: 0,
endTime: '1',
endTimeInMillis: 1,
durationInMillis: 1,
shards: {
total: 3,
failed: 1,
successful: 2,
},
managedRepository: 'found-snapshots',
isLastSuccessfulSnapshot: false,
managedRepository: 'found-snapshots',
isLastSuccessfulSnapshot: false,
});
});
});
describe('serializeSnapshotConfig', () => {
test('serializes config as expected', () => {
const metadata = { test: 'what have you' };
expect(serializeSnapshotConfig({ metadata, indices: '.k*' })).toEqual({
metadata,
indices: ['.k*'],
});
});
test('serializes empty config as expected', () => {
expect(serializeSnapshotConfig({})).toEqual({});
});
});
});

View file

@ -131,10 +131,10 @@ export function deserializeSnapshotConfig(snapshotConfigEs: SnapshotConfigEs): S
export function serializeSnapshotConfig(snapshotConfig: SnapshotConfig): SnapshotConfigEs {
const { indices, ignoreUnavailable, includeGlobalState, partial, metadata } = snapshotConfig;
const indicesArray = csvToArray(indices);
const maybeIndicesArray = csvToArray(indices);
const snapshotConfigEs: SnapshotConfigEs = {
indices: indicesArray,
indices: maybeIndicesArray,
ignore_unavailable: ignoreUnavailable,
include_global_state: includeGlobalState,
partial,

View file

@ -4,10 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const csvToArray = (indices?: string | string[]): string[] => {
export const csvToArray = (indices?: string | string[]): string[] | undefined => {
return indices && Array.isArray(indices)
? indices
: typeof indices === 'string'
? indices.split(',')
: [];
: undefined;
};

View file

@ -24,7 +24,7 @@ const maximumItemPreviewCount = 10;
export const useCollapsibleList = ({ items }: Arg): ReturnValue => {
const [isShowingFullList, setIsShowingFullList] = useState<boolean>(false);
const itemsArray = csvToArray(items);
const itemsArray = csvToArray(items) ?? [];
const displayItems: ChildItems =
items === undefined
? 'all'

View file

@ -35,11 +35,17 @@ export const PolicyStepRetention: React.FunctionComponent<StepProps> = ({
}) => {
const { retention = {} } = policy;
const updatePolicyRetention = (updatedFields: Partial<SlmPolicyPayload['retention']>): void => {
const updatePolicyRetention = (
updatedFields: Partial<SlmPolicyPayload['retention']>,
validationHelperData = {}
): void => {
const newRetention = { ...retention, ...updatedFields };
updatePolicy({
retention: newRetention,
});
updatePolicy(
{
retention: newRetention,
},
validationHelperData
);
};
// State for touched inputs

View file

@ -25,7 +25,7 @@ import {
import { SlmPolicyPayload } from '../../../../../../../../common/types';
import { useServices } from '../../../../../../app_context';
import { PolicyValidation } from '../../../../../../services/validation';
import { PolicyValidation, ValidatePolicyHelperData } from '../../../../../../services/validation';
import { orderDataStreamsAndIndices } from '../../../../../lib';
import { DataStreamBadge } from '../../../../../data_stream_badge';
@ -34,12 +34,16 @@ import { mapSelectionToIndicesOptions, determineListMode } from './helpers';
import { DataStreamsAndIndicesListHelpText } from './data_streams_and_indices_list_help_text';
interface IndicesConfig {
indices?: string[] | string;
}
interface Props {
isManagedPolicy: boolean;
policy: SlmPolicyPayload;
indices: string[];
dataStreams: string[];
onUpdate: (arg: { indices?: string[] | string }) => void;
onUpdate: (arg: IndicesConfig, validateHelperData: ValidatePolicyHelperData) => void;
errors: PolicyValidation['errors'];
}
@ -53,7 +57,7 @@ export const IndicesAndDataStreamsField: FunctionComponent<Props> = ({
dataStreams,
indices,
policy,
onUpdate,
onUpdate: _onUpdate,
errors,
}) => {
const { i18n } = useServices();
@ -66,6 +70,12 @@ export const IndicesAndDataStreamsField: FunctionComponent<Props> = ({
!config.indices || (Array.isArray(config.indices) && config.indices.length === 0)
);
const onUpdate = (data: IndicesConfig) => {
_onUpdate(data, {
validateIndicesCount: !isAllIndices,
});
};
const [indicesAndDataStreamsSelection, setIndicesAndDataStreamsSelection] = useState<string[]>(
() =>
Array.isArray(config.indices) && !isAllIndices

View file

@ -31,11 +31,17 @@ export const PolicyStepSettings: React.FunctionComponent<StepProps> = ({
}) => {
const { config = {}, isManagedPolicy } = policy;
const updatePolicyConfig = (updatedFields: Partial<SlmPolicyPayload['config']>): void => {
const updatePolicyConfig = (
updatedFields: Partial<SlmPolicyPayload['config']>,
validationHelperData = {}
): void => {
const newConfig = { ...config, ...updatedFields };
updatePolicy({
config: newConfig,
});
updatePolicy(
{
config: newConfig,
},
validationHelperData
);
};
const renderIgnoreUnavailableField = () => (

View file

@ -311,7 +311,7 @@ export const RestoreSnapshotStepLogistics: React.FunctionComponent<StepProps> =
});
}
}}
selectedIndicesAndDataStreams={csvToArray(restoreIndices)}
selectedIndicesAndDataStreams={csvToArray(restoreIndices) ?? []}
indices={snapshotIndices}
dataStreams={snapshotDataStreams}
/>

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const attemptToURIDecode = (value: string) => {
let result: string;
try {
result = decodeURI(value);
result = decodeURIComponent(result);
} catch (e1) {
try {
result = decodeURIComponent(value);
} catch (e2) {
result = value;
}
}
return result;
};

View file

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

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useParams } from 'react-router-dom';
import { attemptToURIDecode } from './attempt_to_uri_decode';
export const useDecodedParams = <
Params extends { [K in keyof Params]?: string } = {}
>(): Params => {
const params = useParams<Record<string, string>>();
const decodedParams = {} as Params;
for (const [key, value] of Object.entries(params)) {
if (value) {
(decodedParams as any)[key] = attemptToURIDecode(value);
}
}
return decodedParams;
};

View file

@ -89,7 +89,7 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
}
const onSectionChange = (newSection: Section) => {
history.push(`${BASE_PATH}/${newSection}`);
history.push(encodeURI(`${BASE_PATH}/${encodeURIComponent(newSection)}`));
};
// Set breadcrumb and page title

View file

@ -24,6 +24,8 @@ import {
EuiSpacer,
} from '@elastic/eui';
import { reactRouterNavigate } from '../../../../../../../../../src/plugins/kibana_react/public';
import { SlmPolicy } from '../../../../../../common/types';
import { useServices } from '../../../../app_context';
import { SectionError, Error } from '../../../../../shared_imports';
@ -41,8 +43,6 @@ import {
} from '../../../../components';
import { TabSummary, TabHistory } from './tabs';
import { reactRouterNavigate } from '../../../../../../../../../src/plugins/kibana_react/public';
interface Props {
policyName: SlmPolicy['name'];
onClose: () => void;

View file

@ -9,6 +9,8 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { RouteComponentProps } from 'react-router-dom';
import { EuiEmptyPrompt, EuiButton, EuiCallOut, EuiSpacer } from '@elastic/eui';
import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public';
import {
SectionError,
Error,
@ -20,6 +22,7 @@ import { SlmPolicy } from '../../../../../common/types';
import { APP_SLM_CLUSTER_PRIVILEGES } from '../../../../../common';
import { SectionLoading } from '../../../components';
import { BASE_PATH, UIM_POLICY_LIST_LOAD } from '../../../constants';
import { useDecodedParams } from '../../../lib';
import { useLoadPolicies, useLoadRetentionSettings } from '../../../services/http';
import { linkToAddPolicy, linkToPolicy } from '../../../services/navigation';
import { useServices } from '../../../app_context';
@ -28,18 +31,14 @@ import { PolicyDetails } from './policy_details';
import { PolicyTable } from './policy_table';
import { PolicyRetentionSchedule } from './policy_retention_schedule';
import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public';
interface MatchParams {
policyName?: SlmPolicy['name'];
}
export const PolicyList: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { policyName },
},
history,
}) => {
const { policyName } = useDecodedParams<MatchParams>();
const {
error,
isLoading,

View file

@ -7,11 +7,14 @@
import React, { Fragment, useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { RouteComponentProps } from 'react-router-dom';
import { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public';
import { Repository } from '../../../../../common/types';
import { SectionError, Error } from '../../../../shared_imports';
import { SectionLoading } from '../../../components';
import { useDecodedParams } from '../../../lib';
import { BASE_PATH, UIM_REPOSITORY_LIST_LOAD } from '../../../constants';
import { useServices } from '../../../app_context';
import { useLoadRepositories } from '../../../services/http';
@ -20,18 +23,14 @@ import { linkToAddRepository, linkToRepository } from '../../../services/navigat
import { RepositoryDetails } from './repository_details';
import { RepositoryTable } from './repository_table';
import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public';
interface MatchParams {
repositoryName?: Repository['name'];
}
export const RepositoryList: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { repositoryName },
},
history,
}) => {
const { repositoryName } = useDecodedParams<MatchParams>();
const {
error,
isLoading,

View file

@ -24,6 +24,7 @@ import {
linkToSnapshot,
} from '../../../services/navigation';
import { useServices } from '../../../app_context';
import { useDecodedParams } from '../../../lib';
import { SnapshotDetails } from './snapshot_details';
import { SnapshotTable } from './snapshot_table';
@ -35,12 +36,10 @@ interface MatchParams {
}
export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { repositoryName, snapshotId },
},
location: { search },
history,
}) => {
const { repositoryName, snapshotId } = useDecodedParams<MatchParams>();
const {
error,
isLoading,

View file

@ -43,7 +43,7 @@ export const PolicyAdd: React.FunctionComponent<RouteComponentProps> = ({
if (error) {
setSaveError(error);
} else {
history.push(`${BASE_PATH}/policies/${name}`);
history.push(encodeURI(`${BASE_PATH}/policies/${encodeURIComponent(name)}`));
}
};

View file

@ -10,6 +10,7 @@ import { RouteComponentProps } from 'react-router-dom';
import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle, EuiCallOut } from '@elastic/eui';
import { SlmPolicyPayload } from '../../../../common/types';
import { SectionError, Error } from '../../../shared_imports';
import { useDecodedParams } from '../../lib';
import { TIME_UNITS } from '../../../../common/constants';
import { SectionLoading, PolicyForm } from '../../components';
import { BASE_PATH } from '../../constants';
@ -22,12 +23,10 @@ interface MatchParams {
}
export const PolicyEdit: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { name },
},
history,
location: { pathname },
}) => {
const { name } = useDecodedParams<MatchParams>();
const { i18n } = useServices();
// Set breadcrumb and page title
@ -83,12 +82,12 @@ export const PolicyEdit: React.FunctionComponent<RouteComponentProps<MatchParams
if (error) {
setSaveError(error);
} else {
history.push(`${BASE_PATH}/policies/${name}`);
history.push(encodeURI(`${BASE_PATH}/policies/${encodeURIComponent(name)}`));
}
};
const onCancel = () => {
history.push(`${BASE_PATH}/policies/${name}`);
history.push(encodeURI(`${BASE_PATH}/policies/${encodeURIComponent(name)}`));
};
const renderLoading = () => {

View file

@ -44,7 +44,11 @@ export const RepositoryAdd: React.FunctionComponent<RouteComponentProps> = ({
} else {
const { redirect } = parse(search.replace(/^\?/, ''), { sort: false });
history.push(redirect ? (redirect as string) : `${BASE_PATH}/${section}/${name}`);
history.push(
redirect
? (redirect as string)
: encodeURI(`${BASE_PATH}/${encodeURIComponent(section)}/${encodeURIComponent(name)}`)
);
}
};

View file

@ -16,18 +16,17 @@ import { BASE_PATH, Section } from '../../constants';
import { useServices } from '../../app_context';
import { breadcrumbService, docTitleService } from '../../services/navigation';
import { editRepository, useLoadRepository } from '../../services/http';
import { useDecodedParams } from '../../lib';
interface MatchParams {
name: string;
}
export const RepositoryEdit: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { name },
},
history,
}) => {
const { i18n } = useServices();
const { name } = useDecodedParams<MatchParams>();
const section = 'repositories' as Section;
// Set breadcrumb and page title
@ -70,7 +69,9 @@ export const RepositoryEdit: React.FunctionComponent<RouteComponentProps<MatchPa
if (error) {
setSaveError(error);
} else {
history.push(`${BASE_PATH}/${section}/${name}`);
history.push(
encodeURI(`${BASE_PATH}/${encodeURIComponent(section)}/${encodeURIComponent(name)}`)
);
}
};

View file

@ -15,6 +15,7 @@ import { SectionLoading, RestoreSnapshotForm } from '../../components';
import { useServices } from '../../app_context';
import { breadcrumbService, docTitleService } from '../../services/navigation';
import { useLoadSnapshot, executeRestore } from '../../services/http';
import { useDecodedParams } from '../../lib';
interface MatchParams {
repositoryName: string;
@ -22,12 +23,10 @@ interface MatchParams {
}
export const RestoreSnapshot: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { repositoryName, snapshotId },
},
history,
}) => {
const { i18n } = useServices();
const { repositoryName, snapshotId } = useDecodedParams<MatchParams>();
// Set breadcrumb and page title
useEffect(() => {

View file

@ -13,33 +13,37 @@ export function linkToRepositories() {
}
export function linkToRepository(repositoryName: string) {
return `/repositories/${encodeURIComponent(repositoryName)}`;
return encodeURI(`/repositories/${encodeURIComponent(repositoryName)}`);
}
export function linkToEditRepository(repositoryName: string) {
return `/edit_repository/${encodeURIComponent(repositoryName)}`;
return encodeURI(`/edit_repository/${encodeURIComponent(repositoryName)}`);
}
export function linkToAddRepository(redirect?: string) {
return `/add_repository${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`;
return encodeURI(`/add_repository${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`);
}
export function linkToSnapshots(repositoryName?: string, policyName?: string) {
if (repositoryName) {
return `/snapshots?repository=${encodeURIComponent(repositoryName)}`;
return encodeURI(`/snapshots?repository=${encodeURIComponent(repositoryName)}`);
}
if (policyName) {
return `/snapshots?policy=${encodeURIComponent(policyName)}`;
return encodeURI(`/snapshots?policy=${encodeURIComponent(policyName)}`);
}
return `/snapshots`;
}
export function linkToSnapshot(repositoryName: string, snapshotName: string) {
return `/snapshots/${encodeURIComponent(repositoryName)}/${encodeURIComponent(snapshotName)}`;
return encodeURI(
`/snapshots/${encodeURIComponent(repositoryName)}/${encodeURIComponent(snapshotName)}`
);
}
export function linkToRestoreSnapshot(repositoryName: string, snapshotName: string) {
return `/restore/${encodeURIComponent(repositoryName)}/${encodeURIComponent(snapshotName)}`;
return encodeURI(
`/restore/${encodeURIComponent(repositoryName)}/${encodeURIComponent(snapshotName)}`
);
}
export function linkToPolicies() {
@ -47,11 +51,11 @@ export function linkToPolicies() {
}
export function linkToPolicy(policyName: string) {
return `/policies/${encodeURIComponent(policyName)}`;
return encodeURI(`/policies/${encodeURIComponent(policyName)}`);
}
export function linkToEditPolicy(policyName: string) {
return `/edit_policy/${encodeURIComponent(policyName)}`;
return encodeURI(`/edit_policy/${encodeURIComponent(policyName)}`);
}
export function linkToAddPolicy() {

View file

@ -12,4 +12,4 @@ export {
export { RestoreValidation, validateRestore } from './validate_restore';
export { PolicyValidation, validatePolicy } from './validate_policy';
export { PolicyValidation, validatePolicy, ValidatePolicyHelperData } from './validate_policy';

View file

@ -25,21 +25,32 @@ const isSnapshotNameNotLowerCase = (str: string): boolean => {
return strExcludeDate !== strExcludeDate.toLowerCase() ? true : false;
};
export interface ValidatePolicyHelperData {
managedRepository?: {
name: string;
policy: string;
};
isEditing?: boolean;
policyName?: string;
/**
* Whether to block on the indices configured for this snapshot.
*
* By default ES will back up all indices and data streams if this is an empty array or left blank.
* However, in the UI, under certain conditions, like when displaying indices to select for backup,
* we want to block users from submitting an empty array, but not block the entire form if they
* are not configuring this value - like when they are on a previous step.
*/
validateIndicesCount?: boolean;
}
export const validatePolicy = (
policy: SlmPolicyPayload,
validationHelperData: {
managedRepository?: {
name: string;
policy: string;
};
isEditing?: boolean;
policyName?: string;
}
validationHelperData: ValidatePolicyHelperData
): PolicyValidation => {
const i18n = textService.i18n;
const { name, snapshotName, schedule, repository, config, retention } = policy;
const { managedRepository, isEditing, policyName } = validationHelperData;
const { managedRepository, isEditing, policyName, validateIndicesCount } = validationHelperData;
const validation: PolicyValidation = {
isValid: true,
@ -96,7 +107,12 @@ export const validatePolicy = (
);
}
if (config && typeof config.indices === 'string' && config.indices.trim().length === 0) {
if (
validateIndicesCount &&
config &&
typeof config.indices === 'string' &&
config.indices.trim().length === 0
) {
validation.errors.indices.push(
i18n.translate('xpack.snapshotRestore.policyValidation.indexPatternRequiredErrorMessage', {
defaultMessage: 'At least one index pattern is required.',
@ -104,7 +120,12 @@ export const validatePolicy = (
);
}
if (config && Array.isArray(config.indices) && config.indices.length === 0) {
if (
validateIndicesCount &&
config &&
Array.isArray(config.indices) &&
config.indices.length === 0
) {
validation.errors.indices.push(
i18n.translate('xpack.snapshotRestore.policyValidation.indicesRequiredErrorMessage', {
defaultMessage: 'You must select at least one data stream or index.',