[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; durationInMillis: number;
indexFailures: any[]; indexFailures: any[];
shards: SnapshotDetailsShardsStatus; shards: SnapshotDetailsShardsStatus;
isManagedRepository?: boolean;
} }
interface SnapshotDetailsShardsStatus { interface SnapshotDetailsShardsStatus {

View file

@ -15,9 +15,10 @@ import {
EuiFlexItem, EuiFlexItem,
EuiSpacer, EuiSpacer,
EuiLoadingSpinner, EuiLoadingSpinner,
EuiLink,
} from '@elastic/eui'; } from '@elastic/eui';
import { SectionError, SectionLoading } from '../../../components'; 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 { useAppDependencies } from '../../../index';
import { useLoadRestores } from '../../../services/http'; import { useLoadRestores } from '../../../services/http';
import { useAppState } from '../../../services/state'; import { useAppState } from '../../../services/state';
@ -123,7 +124,7 @@ export const RestoreList: React.FunctionComponent = () => {
<h1> <h1>
<FormattedMessage <FormattedMessage
id="xpack.snapshotRestore.restoreList.emptyPromptTitle" id="xpack.snapshotRestore.restoreList.emptyPromptTitle"
defaultMessage="You don't have any snapshot restores" defaultMessage="You don't have any restored snapshots"
/> />
</h1> </h1>
} }
@ -132,7 +133,17 @@ export const RestoreList: React.FunctionComponent = () => {
<p> <p>
<FormattedMessage <FormattedMessage
id="xpack.snapshotRestore.restoreList.emptyPromptDescription" 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> </p>
</Fragment> </Fragment>

View file

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

View file

@ -199,6 +199,18 @@ export const SnapshotDetails: React.FunctionComponent<Props> = ({
onSnapshotDeleted 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 <FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.deleteButtonLabel" 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 ( return (
<SnapshotDeleteProvider> <SnapshotDeleteProvider>
{deleteSnapshotPrompt => { {deleteSnapshotPrompt => {
const label = i18n.translate( const label = !isManagedRepository
'xpack.snapshotRestore.snapshotList.table.actionDeleteTooltip', ? i18n.translate(
{ defaultMessage: 'Delete' } '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 ( return (
<EuiToolTip content={label}> <EuiToolTip content={label}>
<EuiButtonIcon <EuiButtonIcon
@ -212,6 +220,7 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
onClick={() => onClick={() =>
deleteSnapshotPrompt([{ snapshot, repository }], onSnapshotDeleted) deleteSnapshotPrompt([{ snapshot, repository }], onSnapshotDeleted)
} }
isDisabled={isManagedRepository}
/> />
</EuiToolTip> </EuiToolTip>
); );
@ -248,6 +257,17 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
const selection = { const selection = {
onSelectionChange: (newSelectedItems: SnapshotDetails[]) => setSelectedItems(newSelectedItems), 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 = { 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 { cleanSettings } from './clean_settings';
export { deserializeSnapshotDetails } from './snapshot_serialization'; export { deserializeSnapshotDetails } from './snapshot_serialization';
export { deserializeRestoreShard } from './restore_serialization'; export { deserializeRestoreShard } from './restore_serialization';
export { getManagedRepositoryName } from './get_managed_repository_name';

View file

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

View file

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

View file

@ -13,6 +13,6 @@ import { registerRestoreRoutes } from './restore';
export const registerRoutes = (router: Router, plugins: Plugins): void => { export const registerRoutes = (router: Router, plugins: Plugins): void => {
registerAppRoutes(router, plugins); registerAppRoutes(router, plugins);
registerRepositoriesRoutes(router, plugins); registerRepositoriesRoutes(router, plugins);
registerSnapshotsRoutes(router); registerSnapshotsRoutes(router, plugins);
registerRestoreRoutes(router); 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 { Repository, RepositoryType, RepositoryVerification } from '../../../common/types';
import { Plugins } from '../../../shim'; import { Plugins } from '../../../shim';
import { deserializeRepositorySettings, serializeRepositorySettings } from '../../lib'; import {
deserializeRepositorySettings,
serializeRepositorySettings,
getManagedRepositoryName,
} from '../../lib';
let isCloudEnabled: boolean = false; let isCloudEnabled: boolean = false;
let callWithInternalUser: any; let callWithInternalUser: any;
@ -30,20 +34,6 @@ export function registerRepositoriesRoutes(router: Router, plugins: Plugins) {
router.delete('repositories/{names}', deleteHandler); 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 ( export const getAllHandler: RouterRouteHandler = async (
req, req,
callWithRequest callWithRequest
@ -51,7 +41,7 @@ export const getAllHandler: RouterRouteHandler = async (
repositories: Repository[]; repositories: Repository[];
managedRepository?: string; managedRepository?: string;
}> => { }> => {
const managedRepository = await getManagedRepositoryName(); const managedRepository = await getManagedRepositoryName(callWithInternalUser);
const repositoriesByName = await callWithRequest('snapshot.getRepository', { const repositoriesByName = await callWithRequest('snapshot.getRepository', {
repository: '_all', repository: '_all',
}); });
@ -76,7 +66,7 @@ export const getOneHandler: RouterRouteHandler = async (
snapshots: { count: number | null } | {}; snapshots: { count: number | null } | {};
}> => { }> => {
const { name } = req.params; const { name } = req.params;
const managedRepository = await getManagedRepositoryName(); const managedRepository = await getManagedRepositoryName(callWithInternalUser);
const repositoryByName = await callWithRequest('snapshot.getRepository', { repository: name }); const repositoryByName = await callWithRequest('snapshot.getRepository', { repository: name });
const { const {
responses: snapshotResponses, responses: snapshotResponses,

View file

@ -5,7 +5,7 @@
*/ */
import { Request, ResponseToolkit } from 'hapi'; import { Request, ResponseToolkit } from 'hapi';
import { getAllHandler, getOneHandler, deleteHandler } from './snapshots'; import { registerSnapshotsRoutes, getAllHandler, getOneHandler, deleteHandler } from './snapshots';
const defaultSnapshot = { const defaultSnapshot = {
repository: undefined, repository: undefined,
@ -27,6 +27,29 @@ const defaultSnapshot = {
describe('[Snapshot and Restore API Routes] Snapshots', () => { describe('[Snapshot and Restore API Routes] Snapshots', () => {
const mockResponseToolkit = {} as ResponseToolkit; 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()', () => { describe('getAllHandler()', () => {
const mockRequest = {} as Request; const mockRequest = {} as Request;
@ -65,8 +88,18 @@ describe('[Snapshot and Restore API Routes] Snapshots', () => {
errors: {}, errors: {},
repositories: ['fooRepository', 'barRepository'], repositories: ['fooRepository', 'barRepository'],
snapshots: [ 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 () => { test('returns empty arrays if no snapshots returned from ES', async () => {
const mockSnapshotGetRepositoryEsResponse = {}; const mockSnapshotGetRepositoryEsResponse = {};
const callWithRequest = jest.fn().mockReturnValue(mockSnapshotGetRepositoryEsResponse); const callWithRequest = jest.fn().mockReturnValue(mockSnapshotGetRepositoryEsResponse);
const expectedResponse = { errors: [], snapshots: [], repositories: [] }; const expectedResponse = {
errors: [],
snapshots: [],
repositories: [],
};
const response = await getAllHandler(mockRequest, callWithRequest, mockResponseToolkit); const response = await getAllHandler(mockRequest, callWithRequest, mockResponseToolkit);
expect(response).toEqual(expectedResponse); expect(response).toEqual(expectedResponse);
@ -119,6 +156,7 @@ describe('[Snapshot and Restore API Routes] Snapshots', () => {
...defaultSnapshot, ...defaultSnapshot,
snapshot, snapshot,
repository, repository,
isManagedRepository: false,
}; };
const response = await getOneHandler(mockOneRequest, callWithRequest, mockResponseToolkit); const response = await getOneHandler(mockOneRequest, callWithRequest, mockResponseToolkit);

View file

@ -9,10 +9,14 @@ import {
wrapCustomError, wrapCustomError,
} from '../../../../../server/lib/create_router/error_wrappers'; } from '../../../../../server/lib/create_router/error_wrappers';
import { SnapshotDetails } from '../../../common/types'; import { SnapshotDetails } from '../../../common/types';
import { deserializeSnapshotDetails } from '../../lib'; import { Plugins } from '../../../shim';
import { deserializeSnapshotDetails, getManagedRepositoryName } from '../../lib';
import { SnapshotDetailsEs } from '../../types'; 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', getAllHandler);
router.get('snapshots/{repository}/{snapshot}', getOneHandler); router.get('snapshots/{repository}/{snapshot}', getOneHandler);
router.delete('snapshots/{ids}', deleteHandler); router.delete('snapshots/{ids}', deleteHandler);
@ -25,7 +29,10 @@ export const getAllHandler: RouterRouteHandler = async (
snapshots: SnapshotDetails[]; snapshots: SnapshotDetails[];
errors: any[]; errors: any[];
repositories: string[]; 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` * 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 * 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. // Decorate each snapshot with the repository with which it's associated.
fetchedResponses.forEach(({ snapshots: fetchedSnapshots }) => { fetchedResponses.forEach(({ snapshots: fetchedSnapshots }) => {
fetchedSnapshots.forEach(snapshot => { fetchedSnapshots.forEach(snapshot => {
snapshots.push(deserializeSnapshotDetails(repository, snapshot)); snapshots.push(deserializeSnapshotDetails(repository, snapshot, managedRepository));
}); });
}); });
@ -90,6 +97,7 @@ export const getOneHandler: RouterRouteHandler = async (
callWithRequest callWithRequest
): Promise<SnapshotDetails> => { ): Promise<SnapshotDetails> => {
const { repository, snapshot } = req.params; const { repository, snapshot } = req.params;
const managedRepository = await getManagedRepositoryName(callWithInternalUser);
const { const {
responses: snapshotResponses, responses: snapshotResponses,
}: { }: {
@ -104,7 +112,11 @@ export const getOneHandler: RouterRouteHandler = async (
}); });
if (snapshotResponses && snapshotResponses[0] && snapshotResponses[0].snapshots) { 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 // If snapshot doesn't exist, ES will return 200 with an error object, so manually throw 404 here