[SR] Prevent snapshots in Cloud-managed repository from being deleted in the UI (#40104)

* Extract `getManagedRepositoryName` to `lib/`

* Prevent managed repository snapshots from being deleted in table UI

* Prevent delete of managed repository snapshot from its details UI

* Fix test

* PR feedback and empty restore tab copy edits
This commit is contained in:
Jen Huang 2019-07-02 12:16:48 -07:00 committed by GitHub
parent 0a6e22103b
commit 2eeea97fa8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 156 additions and 35 deletions

View file

@ -22,6 +22,7 @@ export interface SnapshotDetails {
durationInMillis: number;
indexFailures: any[];
shards: SnapshotDetailsShardsStatus;
isManagedRepository?: boolean;
}
interface SnapshotDetailsShardsStatus {

View file

@ -15,9 +15,10 @@ import {
EuiFlexItem,
EuiSpacer,
EuiLoadingSpinner,
EuiLink,
} from '@elastic/eui';
import { SectionError, SectionLoading } from '../../../components';
import { UIM_RESTORE_LIST_LOAD } from '../../../constants';
import { UIM_RESTORE_LIST_LOAD, BASE_PATH } from '../../../constants';
import { useAppDependencies } from '../../../index';
import { useLoadRestores } from '../../../services/http';
import { useAppState } from '../../../services/state';
@ -123,7 +124,7 @@ export const RestoreList: React.FunctionComponent = () => {
<h1>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.emptyPromptTitle"
defaultMessage="You don't have any snapshot restores"
defaultMessage="You don't have any restored snapshots"
/>
</h1>
}
@ -132,7 +133,17 @@ export const RestoreList: React.FunctionComponent = () => {
<p>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.emptyPromptDescription"
defaultMessage="Track progress of indices that are restored from snapshots."
defaultMessage="Go to {snapshotsLink} to start a restore."
values={{
snapshotsLink: (
<EuiLink href={`#${BASE_PATH}/snapshots`}>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.emptyPromptDescriptionLink"
defaultMessage="Snapshots"
/>
</EuiLink>
),
}}
/>
</p>
</Fragment>

View file

@ -81,6 +81,7 @@ export const ShardsTable: React.FunctionComponent<Props> = ({ shards }) => {
<FormattedMessage
id="xpack.snapshotRestore.restoreList.shardTable.primaryAbbreviationText"
defaultMessage="P"
description="Used as an abbreviation for 'Primary', as in 'Primary shard'"
/>
</strong>
</EuiToolTip>

View file

@ -199,6 +199,18 @@ export const SnapshotDetails: React.FunctionComponent<Props> = ({
onSnapshotDeleted
)
}
isDisabled={snapshotDetails.isManagedRepository}
title={
snapshotDetails.isManagedRepository
? i18n.translate(
'xpack.snapshotRestore.snapshotDetails.deleteManagedRepositorySnapshotButtonTitle',
{
defaultMessage:
'You cannot delete a snapshot stored in a managed repository.',
}
)
: null
}
>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.deleteButtonLabel"

View file

@ -188,14 +188,22 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
},
},
{
render: ({ snapshot, repository }: SnapshotDetails) => {
render: ({ snapshot, repository, isManagedRepository }: SnapshotDetails) => {
return (
<SnapshotDeleteProvider>
{deleteSnapshotPrompt => {
const label = i18n.translate(
'xpack.snapshotRestore.snapshotList.table.actionDeleteTooltip',
{ defaultMessage: 'Delete' }
);
const label = !isManagedRepository
? i18n.translate(
'xpack.snapshotRestore.snapshotList.table.actionDeleteTooltip',
{ defaultMessage: 'Delete' }
)
: i18n.translate(
'xpack.snapshotRestore.snapshotList.table.deleteManagedRepositorySnapshotTooltip',
{
defaultMessage:
'You cannot delete a snapshot stored in a managed repository.',
}
);
return (
<EuiToolTip content={label}>
<EuiButtonIcon
@ -212,6 +220,7 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
onClick={() =>
deleteSnapshotPrompt([{ snapshot, repository }], onSnapshotDeleted)
}
isDisabled={isManagedRepository}
/>
</EuiToolTip>
);
@ -248,6 +257,17 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
const selection = {
onSelectionChange: (newSelectedItems: SnapshotDetails[]) => setSelectedItems(newSelectedItems),
selectable: ({ isManagedRepository }: SnapshotDetails) => !isManagedRepository,
selectableMessage: (selectable: boolean) => {
if (!selectable) {
return i18n.translate(
'xpack.snapshotRestore.snapshotList.table.deleteManagedRepositorySnapshotTooltip',
{
defaultMessage: 'You cannot delete a snapshot stored in a managed repository.',
}
);
}
},
};
const search = {

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
// Cloud has its own system for managing snapshots and we want to make
// this clear when Snapshot and Restore is used in a Cloud deployment.
// Retrieve the Cloud-managed repository name so that UI can switch
// logical paths based on this information.
export const getManagedRepositoryName = async (
callWithInternalUser: any
): Promise<string | undefined> => {
try {
const { persistent, transient, defaults } = await callWithInternalUser('cluster.getSettings', {
filterPath: '*.*managed_repository',
flatSettings: true,
includeDefaults: true,
});
const { 'cluster.metadata.managed_repository': managedRepositoryName = undefined } = {
...defaults,
...persistent,
...transient,
};
return managedRepositoryName;
} catch (e) {
// Silently swallow error and return undefined for managed repository name
// so that downstream calls are not blocked. In a healthy environment we do
// not expect to reach here.
return;
}
};

View file

@ -11,3 +11,4 @@ export {
export { cleanSettings } from './clean_settings';
export { deserializeSnapshotDetails } from './snapshot_serialization';
export { deserializeRestoreShard } from './restore_serialization';
export { getManagedRepositoryName } from './get_managed_repository_name';

View file

@ -91,6 +91,7 @@ describe('deserializeSnapshotDetails', () => {
failed: 1,
successful: 2,
},
isManagedRepository: false,
});
});
});

View file

@ -11,7 +11,8 @@ import { SnapshotDetailsEs } from '../types';
export function deserializeSnapshotDetails(
repository: string,
snapshotDetailsEs: SnapshotDetailsEs
snapshotDetailsEs: SnapshotDetailsEs,
managedRepository?: string
): SnapshotDetails {
if (!snapshotDetailsEs || typeof snapshotDetailsEs !== 'object') {
throw new Error('Unable to deserialize snapshot details');
@ -75,5 +76,6 @@ export function deserializeSnapshotDetails(
durationInMillis,
indexFailures,
shards,
isManagedRepository: repository === managedRepository,
};
}

View file

@ -13,6 +13,6 @@ import { registerRestoreRoutes } from './restore';
export const registerRoutes = (router: Router, plugins: Plugins): void => {
registerAppRoutes(router, plugins);
registerRepositoriesRoutes(router, plugins);
registerSnapshotsRoutes(router);
registerSnapshotsRoutes(router, plugins);
registerRestoreRoutes(router);
};

View file

@ -13,7 +13,11 @@ import { DEFAULT_REPOSITORY_TYPES, REPOSITORY_PLUGINS_MAP } from '../../../commo
import { Repository, RepositoryType, RepositoryVerification } from '../../../common/types';
import { Plugins } from '../../../shim';
import { deserializeRepositorySettings, serializeRepositorySettings } from '../../lib';
import {
deserializeRepositorySettings,
serializeRepositorySettings,
getManagedRepositoryName,
} from '../../lib';
let isCloudEnabled: boolean = false;
let callWithInternalUser: any;
@ -30,20 +34,6 @@ export function registerRepositoriesRoutes(router: Router, plugins: Plugins) {
router.delete('repositories/{names}', deleteHandler);
}
export const getManagedRepositoryName = async () => {
const { persistent, transient, defaults } = await callWithInternalUser('cluster.getSettings', {
filterPath: '*.*managed_repository',
flatSettings: true,
includeDefaults: true,
});
const { 'cluster.metadata.managed_repository': managedRepositoryName = undefined } = {
...defaults,
...persistent,
...transient,
};
return managedRepositoryName;
};
export const getAllHandler: RouterRouteHandler = async (
req,
callWithRequest
@ -51,7 +41,7 @@ export const getAllHandler: RouterRouteHandler = async (
repositories: Repository[];
managedRepository?: string;
}> => {
const managedRepository = await getManagedRepositoryName();
const managedRepository = await getManagedRepositoryName(callWithInternalUser);
const repositoriesByName = await callWithRequest('snapshot.getRepository', {
repository: '_all',
});
@ -76,7 +66,7 @@ export const getOneHandler: RouterRouteHandler = async (
snapshots: { count: number | null } | {};
}> => {
const { name } = req.params;
const managedRepository = await getManagedRepositoryName();
const managedRepository = await getManagedRepositoryName(callWithInternalUser);
const repositoryByName = await callWithRequest('snapshot.getRepository', { repository: name });
const {
responses: snapshotResponses,

View file

@ -5,7 +5,7 @@
*/
import { Request, ResponseToolkit } from 'hapi';
import { getAllHandler, getOneHandler, deleteHandler } from './snapshots';
import { registerSnapshotsRoutes, getAllHandler, getOneHandler, deleteHandler } from './snapshots';
const defaultSnapshot = {
repository: undefined,
@ -27,6 +27,29 @@ const defaultSnapshot = {
describe('[Snapshot and Restore API Routes] Snapshots', () => {
const mockResponseToolkit = {} as ResponseToolkit;
const mockCallWithInternalUser = jest.fn().mockReturnValue({
persistent: {
'cluster.metadata.managed_repository': 'found-snapshots',
},
});
registerSnapshotsRoutes(
{
// @ts-ignore
get: () => {},
// @ts-ignore
post: () => {},
// @ts-ignore
put: () => {},
// @ts-ignore
delete: () => {},
// @ts-ignore
patch: () => {},
},
{
elasticsearch: { getCluster: () => ({ callWithInternalUser: mockCallWithInternalUser }) },
}
);
describe('getAllHandler()', () => {
const mockRequest = {} as Request;
@ -65,8 +88,18 @@ describe('[Snapshot and Restore API Routes] Snapshots', () => {
errors: {},
repositories: ['fooRepository', 'barRepository'],
snapshots: [
{ ...defaultSnapshot, repository: 'fooRepository', snapshot: 'snapshot1' },
{ ...defaultSnapshot, repository: 'barRepository', snapshot: 'snapshot2' },
{
...defaultSnapshot,
repository: 'fooRepository',
snapshot: 'snapshot1',
isManagedRepository: false,
},
{
...defaultSnapshot,
repository: 'barRepository',
snapshot: 'snapshot2',
isManagedRepository: false,
},
],
};
@ -77,7 +110,11 @@ describe('[Snapshot and Restore API Routes] Snapshots', () => {
test('returns empty arrays if no snapshots returned from ES', async () => {
const mockSnapshotGetRepositoryEsResponse = {};
const callWithRequest = jest.fn().mockReturnValue(mockSnapshotGetRepositoryEsResponse);
const expectedResponse = { errors: [], snapshots: [], repositories: [] };
const expectedResponse = {
errors: [],
snapshots: [],
repositories: [],
};
const response = await getAllHandler(mockRequest, callWithRequest, mockResponseToolkit);
expect(response).toEqual(expectedResponse);
@ -119,6 +156,7 @@ describe('[Snapshot and Restore API Routes] Snapshots', () => {
...defaultSnapshot,
snapshot,
repository,
isManagedRepository: false,
};
const response = await getOneHandler(mockOneRequest, callWithRequest, mockResponseToolkit);

View file

@ -9,10 +9,14 @@ import {
wrapCustomError,
} from '../../../../../server/lib/create_router/error_wrappers';
import { SnapshotDetails } from '../../../common/types';
import { deserializeSnapshotDetails } from '../../lib';
import { Plugins } from '../../../shim';
import { deserializeSnapshotDetails, getManagedRepositoryName } from '../../lib';
import { SnapshotDetailsEs } from '../../types';
export function registerSnapshotsRoutes(router: Router) {
let callWithInternalUser: any;
export function registerSnapshotsRoutes(router: Router, plugins: Plugins) {
callWithInternalUser = plugins.elasticsearch.getCluster('data').callWithInternalUser;
router.get('snapshots', getAllHandler);
router.get('snapshots/{repository}/{snapshot}', getOneHandler);
router.delete('snapshots/{ids}', deleteHandler);
@ -25,7 +29,10 @@ export const getAllHandler: RouterRouteHandler = async (
snapshots: SnapshotDetails[];
errors: any[];
repositories: string[];
managedRepository?: string;
}> => {
const managedRepository = await getManagedRepositoryName(callWithInternalUser);
/*
* TODO: For 8.0, replace the logic in this handler with one call to `GET /_snapshot/_all/_all`
* when no repositories bug is fixed: https://github.com/elastic/elasticsearch/issues/43547
@ -64,7 +71,7 @@ export const getAllHandler: RouterRouteHandler = async (
// Decorate each snapshot with the repository with which it's associated.
fetchedResponses.forEach(({ snapshots: fetchedSnapshots }) => {
fetchedSnapshots.forEach(snapshot => {
snapshots.push(deserializeSnapshotDetails(repository, snapshot));
snapshots.push(deserializeSnapshotDetails(repository, snapshot, managedRepository));
});
});
@ -90,6 +97,7 @@ export const getOneHandler: RouterRouteHandler = async (
callWithRequest
): Promise<SnapshotDetails> => {
const { repository, snapshot } = req.params;
const managedRepository = await getManagedRepositoryName(callWithInternalUser);
const {
responses: snapshotResponses,
}: {
@ -104,7 +112,11 @@ export const getOneHandler: RouterRouteHandler = async (
});
if (snapshotResponses && snapshotResponses[0] && snapshotResponses[0].snapshots) {
return deserializeSnapshotDetails(repository, snapshotResponses[0].snapshots[0]);
return deserializeSnapshotDetails(
repository,
snapshotResponses[0].snapshots[0],
managedRepository
);
}
// If snapshot doesn't exist, ES will return 200 with an error object, so manually throw 404 here