diff --git a/x-pack/plugins/snapshot_restore/README.md b/x-pack/plugins/snapshot_restore/README.md index b6b75631b07d9..11bd590a3767e 100644 --- a/x-pack/plugins/snapshot_restore/README.md +++ b/x-pack/plugins/snapshot_restore/README.md @@ -74,4 +74,66 @@ To run ES with plugins: 1. Run `yarn es snapshot` from the Kibana directory like normal, then exit out of process. 2. `cd .es/8.0.0` 3. `bin/elasticsearch-plugin install https://snapshots.elastic.co/downloads/elasticsearch-plugins/repository-hdfs/repository-hdfs-8.0.0-SNAPSHOT.zip` -4. Run `bin/elasticsearch` from the `.es/8.0.0` directory. Otherwise, starting ES with `yarn es snapshot` would overwrite the plugins you just installed. \ No newline at end of file +4. Run `bin/elasticsearch` from the `.es/8.0.0` directory. Otherwise, starting ES with `yarn es snapshot` would overwrite the plugins you just installed. + +### Cloud-managed repositories + +Cloud-managed repositories can be imitated when Kibana is running locally by following the steps below: + +1. Add the file system path you want to use to elasticsearch.yml or as part of starting up ES. Note that this path should point to a directory that exists. + +``` +path: + repo: /tmp/es-backups +``` + +or + +``` +yarn es snapshot --license=trial -E path.repo=/tmp/es-backups +``` + +2. Use Console to add the `cluster.metadata.managed_repository` and `cluster.metadata.managed_policies` settings: + +``` +PUT /_cluster/settings +{ + "persistent": { + "cluster.metadata.managed_repository": "found-snapshots", + "cluster.metadata.managed_policies": ["managed-policy"] + } +} +``` + +3. Use Console or UI to create a repository with the same name as your setting value (`found-snapshots`). Use the file system path from the first step as the `location` setting: + +``` +PUT /_snapshot/found-snapshots +{ + "type": "fs", + "settings": { + "location": "/tmp/es-backups" + } +} +``` + +4. Use Console or UI to create a policy with the same name as your setting value (`managed-policy`) + +``` +PUT _slm/policy/managed-policy +{ + "name": "managed-snap", + "schedule": "0 30 1 * * ?", + "repository": "found-snapshots", + "config": { + "include_global_state": true, + "feature_states": [] + } +} +``` + +5. Execute the created policy to create a managed snapshot: + +``` +POST _slm/policy/managed-policy/_execute +``` \ No newline at end of file diff --git a/x-pack/plugins/snapshot_restore/__jest__/client_integration/snapshot_list.test.tsx b/x-pack/plugins/snapshot_restore/__jest__/client_integration/snapshot_list.test.tsx index 8d4133ee48373..65169083ad5a8 100644 --- a/x-pack/plugins/snapshot_restore/__jest__/client_integration/snapshot_list.test.tsx +++ b/x-pack/plugins/snapshot_restore/__jest__/client_integration/snapshot_list.test.tsx @@ -26,6 +26,9 @@ import { pageHelpers, getRandomString } from './helpers'; */ jest.mock('../../public/application/services/http', () => ({ useLoadSnapshots: jest.fn(), + useLastSuccessfulManagedSnapshot: () => { + return { data: undefined }; + }, setUiMetricServiceSnapshot: () => {}, setUiMetricService: () => {}, })); diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx index 1272d841173c6..b00860a6841a3 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_table.tsx @@ -30,21 +30,6 @@ import { SnapshotListParams, SortDirection, SortField } from '../../../../lib'; import { DataPlaceholder, FormattedDateTime, SnapshotDeleteProvider } from '../../../../components'; import { SnapshotSearchBar } from './snapshot_search_bar'; -const getLastSuccessfulManagedSnapshot = ( - snapshots: SnapshotDetails[] -): SnapshotDetails | undefined => { - const successfulSnapshots = snapshots - .filter( - ({ state, repository, managedRepository }) => - repository === managedRepository && state === 'SUCCESS' - ) - .sort((a, b) => { - return +new Date(b.endTime) - +new Date(a.endTime); - }); - - return successfulSnapshots[0]; -}; - interface Props { snapshots: SnapshotDetails[]; repositories: string[]; @@ -54,6 +39,7 @@ interface Props { setListParams: (listParams: SnapshotListParams) => void; totalItemCount: number; isLoading: boolean; + lastSuccessfulManagedSnapshot?: SnapshotDetails; } export const SnapshotTable: React.FunctionComponent = (props: Props) => { @@ -66,12 +52,11 @@ export const SnapshotTable: React.FunctionComponent = (props: Props) => { setListParams, totalItemCount, isLoading, + lastSuccessfulManagedSnapshot, } = props; const { i18n, uiMetricService, history } = useServices(); const [selectedItems, setSelectedItems] = useState([]); - const lastSuccessfulManagedSnapshot = getLastSuccessfulManagedSnapshot(snapshots); - const columns = [ { field: 'snapshot', diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx index 0ff63cd990d6e..21c88d0b147db 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx @@ -19,7 +19,7 @@ import { useExecutionContext, } from '../../../../shared_imports'; import { BASE_PATH, UIM_SNAPSHOT_LIST_LOAD } from '../../../constants'; -import { useLoadSnapshots } from '../../../services/http'; +import { useLoadSnapshots, useLastSuccessfulManagedSnapshot } from '../../../services/http'; import { linkToRepositories } from '../../../services/navigation'; import { useAppContext, useServices } from '../../../app_context'; import { useDecodedParams, SnapshotListParams, DEFAULT_SNAPSHOT_LIST_PARAMS } from '../../../lib'; @@ -42,6 +42,7 @@ export const SnapshotList: React.FunctionComponent { const { repositoryName, snapshotId } = useDecodedParams(); + const lastSuccessfulManagedSnapshot = useLastSuccessfulManagedSnapshot().data; const [listParams, setListParams] = useState(DEFAULT_SNAPSHOT_LIST_PARAMS); const { error, @@ -197,6 +198,7 @@ export const SnapshotList: React.FunctionComponent ); diff --git a/x-pack/plugins/snapshot_restore/public/application/services/http/snapshot_requests.ts b/x-pack/plugins/snapshot_restore/public/application/services/http/snapshot_requests.ts index 7ce0f9259cca4..a42fae36ea6cb 100644 --- a/x-pack/plugins/snapshot_restore/public/application/services/http/snapshot_requests.ts +++ b/x-pack/plugins/snapshot_restore/public/application/services/http/snapshot_requests.ts @@ -50,3 +50,9 @@ export const deleteSnapshots = async ( ); return result; }; + +export const useLastSuccessfulManagedSnapshot = () => + useRequest({ + path: `${API_BASE_PATH}snapshots/last_successful_managed_snapshot`, + method: 'get', + }); diff --git a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts index c486180424da5..f445eac771498 100644 --- a/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts +++ b/x-pack/plugins/snapshot_restore/server/routes/api/snapshots.ts @@ -267,4 +267,56 @@ export function registerSnapshotsRoutes({ } }) ); + + // GET last successful managed snapshot + router.get( + { + path: addBasePath('snapshots/last_successful_managed_snapshot'), + validate: false, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { client: clusterClient } = (await ctx.core).elasticsearch; + const managedRepository = await getManagedRepositoryName(clusterClient.asCurrentUser); + + if (managedRepository === undefined) { + return res.ok({ + body: undefined, + }); + } + + try { + const response = await clusterClient.asCurrentUser.snapshot.get({ + repository: managedRepository, + snapshot: '_all', + ignore_unavailable: true, + sort: 'start_time', + order: 'desc', + }); + + const { snapshots: managedSnapshotsList } = response; + + if (!managedSnapshotsList || managedSnapshotsList.length === 0) { + return res.ok({ + body: undefined, + }); + } + + const successfulManagedSnapshots = managedSnapshotsList.filter( + ({ state }) => state === 'SUCCESS' + ) as SnapshotDetailsEs[]; + + if (successfulManagedSnapshots.length === 0) { + return res.ok({ + body: undefined, + }); + } + + return res.ok({ + body: deserializeSnapshotDetails(successfulManagedSnapshots[0]), + }); + } catch (e) { + return handleEsError({ error: e, response: res }); + } + }) + ); } diff --git a/x-pack/test/functional/apps/snapshot_restore/index.ts b/x-pack/test/functional/apps/snapshot_restore/index.ts index 7aa26dce00af6..797533b870a9d 100644 --- a/x-pack/test/functional/apps/snapshot_restore/index.ts +++ b/x-pack/test/functional/apps/snapshot_restore/index.ts @@ -12,5 +12,6 @@ export default ({ loadTestFile }: FtrProviderContext) => { this.tags('skipCloud'); loadTestFile(require.resolve('./home_page')); loadTestFile(require.resolve('./snapshot_restore')); + loadTestFile(require.resolve('./snapshot_list')); }); }; diff --git a/x-pack/test/functional/apps/snapshot_restore/snapshot_list.ts b/x-pack/test/functional/apps/snapshot_restore/snapshot_list.ts new file mode 100644 index 0000000000000..2fc7d5ada0516 --- /dev/null +++ b/x-pack/test/functional/apps/snapshot_restore/snapshot_list.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const pageObjects = getPageObjects(['common', 'snapshotRestore', 'header']); + const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + const es = getService('es'); + const security = getService('security'); + + describe('Snapshot list', function () { + describe('Managed snapshots', function () { + const FIRST_SNAPSHOT_NAME = 'managed-snapshot-1'; + const SECOND_SNAPSHOT_NAME = 'managed-snapshot-2'; + const MANAGED_REPO_NAME = 'found-snapshots'; + + before(async () => { + await security.testUser.setRoles(['snapshot_restore_user'], { skipBrowserRefresh: true }); + await pageObjects.common.navigateToApp('snapshotRestore'); + + // Set cluster settings for manager repository + await es.cluster.putSettings({ + persistent: { + 'cluster.metadata.managed_repository': 'found-snapshots', + }, + }); + + // Create a managed repository + await es.snapshot.createRepository({ + name: MANAGED_REPO_NAME, + type: 'fs', + settings: { + location: '/tmp/es-backups/', + compress: true, + }, + verify: true, + }); + + // Create managed snapshots + await es.snapshot.create({ + snapshot: FIRST_SNAPSHOT_NAME, + repository: MANAGED_REPO_NAME, + }); + + await es.snapshot.create({ + snapshot: SECOND_SNAPSHOT_NAME, + repository: MANAGED_REPO_NAME, + }); + + // Wait for snapshots to be ready + await pageObjects.common.sleep(3000); + + // Refresh page so that the snapshots show up in the snapshots table + await browser.refresh(); + }); + + it('Last successful managed snapshot is non-deletable', async () => { + const snapshots = await pageObjects.snapshotRestore.getSnapshotList(); + const lastSuccessfulSnapshotDeleteButton = snapshots.find( + (snapshot) => snapshot.snapshotName === SECOND_SNAPSHOT_NAME + )?.snapshotDelete; + expect(lastSuccessfulSnapshotDeleteButton).to.not.be(null); + + const firstSuccessfulSnapshotDeleteButton = snapshots.find( + (snapshot) => snapshot.snapshotName === FIRST_SNAPSHOT_NAME + )?.snapshotDelete; + expect(firstSuccessfulSnapshotDeleteButton).to.not.be(null); + + expect(await lastSuccessfulSnapshotDeleteButton?.isEnabled()).to.be(false); + expect(await firstSuccessfulSnapshotDeleteButton?.isEnabled()).to.be(true); + }); + + it('Last successful managed snapshot is correct when snapshots are filtered', async () => { + // Filter out last successful managed snapshot + await testSubjects.setValue('snapshotListSearch', FIRST_SNAPSHOT_NAME); + // Wait for filter to be applies + await pageObjects.common.sleep(1000); + const snapshots = await pageObjects.snapshotRestore.getSnapshotList(); + const firstSuccessfulSnapshotDeleteButton = snapshots.find( + (snapshot) => snapshot.snapshotName === FIRST_SNAPSHOT_NAME + )?.snapshotDelete; + // Verify that the first successful snapshot is in the list and is deletable + expect(firstSuccessfulSnapshotDeleteButton).to.not.be(null); + expect(await firstSuccessfulSnapshotDeleteButton?.isEnabled()).to.be(true); + + // Filter out first successful managed snapshot + await testSubjects.setValue('snapshotListSearch', SECOND_SNAPSHOT_NAME); + // Wait for filter to be applies + await pageObjects.common.sleep(1000); + const newSnapshots = await pageObjects.snapshotRestore.getSnapshotList(); + const lastSuccessfulSnapshotDeleteButton = newSnapshots.find( + (snapshot) => snapshot.snapshotName === SECOND_SNAPSHOT_NAME + )?.snapshotDelete; + // Verify that the last successful snapshot is in the list and is non-deletable + expect(lastSuccessfulSnapshotDeleteButton).to.not.be(null); + expect(await lastSuccessfulSnapshotDeleteButton?.isEnabled()).to.be(false); + }); + + after(async () => { + await es.snapshot.delete({ + snapshot: FIRST_SNAPSHOT_NAME, + repository: MANAGED_REPO_NAME, + }); + await es.snapshot.delete({ + snapshot: SECOND_SNAPSHOT_NAME, + repository: MANAGED_REPO_NAME, + }); + await es.snapshot.deleteRepository({ + name: MANAGED_REPO_NAME, + }); + await security.testUser.restoreDefaults(); + }); + }); + }); +}; diff --git a/x-pack/test/functional/page_objects/snapshot_restore_page.ts b/x-pack/test/functional/page_objects/snapshot_restore_page.ts index 8c41f4a15c7f2..15e020e1855a8 100644 --- a/x-pack/test/functional/page_objects/snapshot_restore_page.ts +++ b/x-pack/test/functional/page_objects/snapshot_restore_page.ts @@ -98,9 +98,11 @@ export function SnapshotRestorePageProvider({ getService }: FtrProviderContext) return await Promise.all( rows.map(async (row) => { return { + snapshotName: await (await row.findByTestSubject('snapshotLink')).getVisibleText(), snapshotLink: await row.findByTestSubject('snapshotLink'), repoLink: await row.findByTestSubject('repositoryLink'), - snapshotRestore: row.findByTestSubject('srsnapshotListRestoreActionButton'), + snapshotRestore: await row.findByTestSubject('srsnapshotListRestoreActionButton'), + snapshotDelete: await row.findByTestSubject('srsnapshotListDeleteActionButton'), }; }) );