Skip to content

Commit

Permalink
[SR] Prevent snapshots in Cloud-managed repository from being deleted…
Browse files Browse the repository at this point in the history
… in the UI (#40104) (#40200)

* 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
  • Loading branch information
jen-huang authored and cjcenizal committed Jul 2, 2019
1 parent 16f189d commit a9c7c2a
Show file tree
Hide file tree
Showing 13 changed files with 151 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface SnapshotDetails {
durationInMillis: number;
indexFailures: any[];
shards: SnapshotDetailsShardsStatus;
isManagedRepository?: boolean;
}

interface SnapshotDetailsShardsStatus {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>
}
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -212,6 +220,7 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
onClick={() =>
deleteSnapshotPrompt([{ snapshot, repository }], onSnapshotDeleted)
}
isDisabled={isManagedRepository}
/>
</EuiToolTip>
);
Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
};
1 change: 1 addition & 0 deletions x-pack/legacy/plugins/snapshot_restore/server/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe('deserializeSnapshotDetails', () => {
failed: 1,
successful: 2,
},
isManagedRepository: false,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -75,5 +76,6 @@ export function deserializeSnapshotDetails(
durationInMillis,
indexFailures,
shards,
isManagedRepository: repository === managedRepository,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,28 +34,14 @@ 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
): Promise<{
repositories: Repository[];
managedRepository?: string;
}> => {
const managedRepository = await getManagedRepositoryName();
const managedRepository = await getManagedRepositoryName(callWithInternalUser);
const repositoriesByName = await callWithRequest('snapshot.getRepository', {
repository: '_all',
});
Expand All @@ -76,7 +66,7 @@ export const getOneHandler: RouterRouteHandler = async (
snapshots: { count: number | undefined } | {};
}> => {
const { name } = req.params;
const managedRepository = await getManagedRepositoryName();
const managedRepository = await getManagedRepositoryName(callWithInternalUser);
const repositoryByName = await callWithRequest('snapshot.getRepository', { repository: name });
const { snapshots } = await callWithRequest('snapshot.get', {
repository: name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -63,8 +86,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,
},
],
};

Expand All @@ -75,7 +108,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);
Expand Down Expand Up @@ -112,6 +149,7 @@ describe('[Snapshot and Restore API Routes] Snapshots', () => {
...defaultSnapshot,
snapshot,
repository,
isManagedRepository: false,
};

const response = await getOneHandler(mockOneRequest, callWithRequest, mockResponseToolkit);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
import { Router, RouterRouteHandler } from '../../../../../server/lib/create_router';
import { wrapEsError } 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);
Expand All @@ -22,7 +26,9 @@ export const getAllHandler: RouterRouteHandler = async (
snapshots: SnapshotDetails[];
errors: any[];
repositories: string[];
managedRepository?: string;
}> => {
const managedRepository = await getManagedRepositoryName(callWithInternalUser);
const repositoriesByName = await callWithRequest('snapshot.getRepository', {
repository: '_all',
});
Expand Down Expand Up @@ -50,7 +56,7 @@ export const getAllHandler: RouterRouteHandler = async (

// Decorate each snapshot with the repository with which it's associated.
fetchedSnapshots.forEach((snapshot: SnapshotDetailsEs) => {
snapshots.push(deserializeSnapshotDetails(repository, snapshot));
snapshots.push(deserializeSnapshotDetails(repository, snapshot, managedRepository));
});

repositories.push(repository);
Expand All @@ -75,13 +81,14 @@ export const getOneHandler: RouterRouteHandler = async (
callWithRequest
): Promise<SnapshotDetails> => {
const { repository, snapshot } = req.params;
const managedRepository = await getManagedRepositoryName(callWithInternalUser);
const { snapshots }: { snapshots: SnapshotDetailsEs[] } = await callWithRequest('snapshot.get', {
repository,
snapshot,
});

// If the snapshot is missing the endpoint will return a 404, so we'll never get to this point.
return deserializeSnapshotDetails(repository, snapshots[0]);
return deserializeSnapshotDetails(repository, snapshots[0], managedRepository);
};

export const deleteHandler: RouterRouteHandler = async (req, callWithRequest) => {
Expand Down

0 comments on commit a9c7c2a

Please sign in to comment.