mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
0a6e22103b
commit
2eeea97fa8
13 changed files with 156 additions and 35 deletions
|
@ -22,6 +22,7 @@ export interface SnapshotDetails {
|
|||
durationInMillis: number;
|
||||
indexFailures: any[];
|
||||
shards: SnapshotDetailsShardsStatus;
|
||||
isManagedRepository?: boolean;
|
||||
}
|
||||
|
||||
interface SnapshotDetailsShardsStatus {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -91,6 +91,7 @@ describe('deserializeSnapshotDetails', () => {
|
|||
failed: 1,
|
||||
successful: 2,
|
||||
},
|
||||
isManagedRepository: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue