From 2eeea97fa8b1ad3c40c08db4beff6cea3deec1ca Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Tue, 2 Jul 2019 12:16:48 -0700 Subject: [PATCH] [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 --- .../snapshot_restore/common/types/snapshot.ts | 1 + .../home/restore_list/restore_list.tsx | 17 +++++-- .../restore_table/shards_table.tsx | 1 + .../snapshot_details/snapshot_details.tsx | 12 +++++ .../snapshot_table/snapshot_table.tsx | 30 ++++++++++-- .../server/lib/get_managed_repository_name.ts | 32 +++++++++++++ .../snapshot_restore/server/lib/index.ts | 1 + .../server/lib/snapshot_serialization.test.ts | 1 + .../server/lib/snapshot_serialization.ts | 4 +- .../server/routes/api/register_routes.ts | 2 +- .../server/routes/api/repositories.ts | 24 +++------- .../server/routes/api/snapshots.test.ts | 46 +++++++++++++++++-- .../server/routes/api/snapshots.ts | 20 ++++++-- 13 files changed, 156 insertions(+), 35 deletions(-) create mode 100644 x-pack/legacy/plugins/snapshot_restore/server/lib/get_managed_repository_name.ts diff --git a/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts b/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts index 94f4fb3c2e52..28bb7346cbcc 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts @@ -22,6 +22,7 @@ export interface SnapshotDetails { durationInMillis: number; indexFailures: any[]; shards: SnapshotDetailsShardsStatus; + isManagedRepository?: boolean; } interface SnapshotDetailsShardsStatus { diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/restore_list/restore_list.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/restore_list/restore_list.tsx index c120b6d9949f..12537cd490ae 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/restore_list/restore_list.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/restore_list/restore_list.tsx @@ -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 = () => {

} @@ -132,7 +133,17 @@ export const RestoreList: React.FunctionComponent = () => {

+ + + ), + }} />

diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/restore_list/restore_table/shards_table.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/restore_list/restore_table/shards_table.tsx index 5b0c2d4fdcbd..0111dbc1fa70 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/restore_list/restore_table/shards_table.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/restore_list/restore_table/shards_table.tsx @@ -81,6 +81,7 @@ export const ShardsTable: React.FunctionComponent = ({ shards }) => { diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/snapshot_details.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/snapshot_details.tsx index 8bb97adb2e70..a8721007101c 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/snapshot_details.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/snapshot_details.tsx @@ -199,6 +199,18 @@ export const SnapshotDetails: React.FunctionComponent = ({ 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 + } > = ({ }, }, { - render: ({ snapshot, repository }: SnapshotDetails) => { + render: ({ snapshot, repository, isManagedRepository }: SnapshotDetails) => { return ( {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 ( = ({ onClick={() => deleteSnapshotPrompt([{ snapshot, repository }], onSnapshotDeleted) } + isDisabled={isManagedRepository} /> ); @@ -248,6 +257,17 @@ export const SnapshotTable: React.FunctionComponent = ({ 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 = { diff --git a/x-pack/legacy/plugins/snapshot_restore/server/lib/get_managed_repository_name.ts b/x-pack/legacy/plugins/snapshot_restore/server/lib/get_managed_repository_name.ts new file mode 100644 index 000000000000..5953657805a1 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/server/lib/get_managed_repository_name.ts @@ -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 => { + 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; + } +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/lib/index.ts b/x-pack/legacy/plugins/snapshot_restore/server/lib/index.ts index 1a57c44d6ac3..da367b6dbd9f 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/lib/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/lib/index.ts @@ -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'; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/lib/snapshot_serialization.test.ts b/x-pack/legacy/plugins/snapshot_restore/server/lib/snapshot_serialization.test.ts index 83ef391348fb..1900580f6c43 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/lib/snapshot_serialization.test.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/lib/snapshot_serialization.test.ts @@ -91,6 +91,7 @@ describe('deserializeSnapshotDetails', () => { failed: 1, successful: 2, }, + isManagedRepository: false, }); }); }); diff --git a/x-pack/legacy/plugins/snapshot_restore/server/lib/snapshot_serialization.ts b/x-pack/legacy/plugins/snapshot_restore/server/lib/snapshot_serialization.ts index 26a585925b34..b3c900749ca9 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/lib/snapshot_serialization.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/lib/snapshot_serialization.ts @@ -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, }; } diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts index 2f1c4b3ace70..dca771621ac4 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts @@ -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); }; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts index a01835d0e67c..bf4d7538b9d3 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts @@ -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, diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.test.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.test.ts index 67357ad10c04..7b55b4a34d07 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.test.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.test.ts @@ -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); diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts index 696a771e3046..84d3f45722e2 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts @@ -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 => { 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