From 7d6e8322267ff30463e1dbfcbed7e5cc9ee70f27 Mon Sep 17 00:00:00 2001 From: Florent BEAUCHAMP Date: Thu, 21 Apr 2022 10:26:36 +0200 Subject: [PATCH] feat(xo-server,xo-web/backups): restore health check (#6148) --- CHANGELOG.unreleased.md | 1 + packages/xo-server/src/api/backup-ng.mjs | 18 ++++++++ .../src/xo-mixins/backups-ng/index.mjs | 42 +++++++++++++++++++ packages/xo-web/src/common/intl/messages.js | 2 + packages/xo-web/src/common/xo/index.js | 8 ++++ packages/xo-web/src/icons.scss | 4 ++ .../xo-web/src/xo-app/backup/restore/index.js | 28 ++++++++++++- .../restore/restore-backups-modal-body.js | 30 ++++++++----- 8 files changed, 120 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index ae5beafcd9d..e29a084ec31 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -8,6 +8,7 @@ > Users must be able to say: “Nice enhancement, I'm eager to test it” - [VM export] Feat export to `ova` format (PR [#6006](https://github.com/vatesfr/xen-orchestra/pull/6006)) +- [Backup] Add *Restore Health Check*: ensure a backup is viable by doing an automatic test restore (requires guest tools in the VM) [#6148](https://github.com/vatesfr/xen-orchestra/pull/6148) ### Bug fixes diff --git a/packages/xo-server/src/api/backup-ng.mjs b/packages/xo-server/src/api/backup-ng.mjs index bd27619ae95..d7c25ce2cdb 100644 --- a/packages/xo-server/src/api/backup-ng.mjs +++ b/packages/xo-server/src/api/backup-ng.mjs @@ -268,6 +268,24 @@ importVmBackup.params = { }, } +export function checkBackup({ id, settings, sr }) { + return this.checkVmBackupNg(id, sr, settings) +} + +checkBackup.permission = 'admin' + +checkBackup.params = { + id: { + type: 'string', + }, + settings: { + type: 'object', + }, + sr: { + type: 'string', + }, +} + // ----------------------------------------------------------------------------- export function listPartitions({ remote, disk }) { diff --git a/packages/xo-server/src/xo-mixins/backups-ng/index.mjs b/packages/xo-server/src/xo-mixins/backups-ng/index.mjs index e29626a00ea..cd05357fc2b 100644 --- a/packages/xo-server/src/xo-mixins/backups-ng/index.mjs +++ b/packages/xo-server/src/xo-mixins/backups-ng/index.mjs @@ -534,4 +534,46 @@ export default class BackupNg { return backupsByVmByRemote } + + async checkVmBackupNg(id, srId, settings) { + let restoredVm, xapi + try { + const restoredId = await this.importVmBackupNg(id, srId, settings) + const app = this._app + xapi = app.getXapi(srId) + restoredVm = xapi.getObject(restoredId) + + // remove vifs + await Promise.all(restoredVm.$VIFs.map(vif => xapi._deleteVif(vif))) + + const start = new Date() + // start Vm + await xapi.startVm(restoredId) + const timeout = 10 * 60 * 1000 + const startDuration = new Date() - start + + if (startDuration >= timeout) { + throw new Error(`VM ${restoredId} not started after ${timeout / 1000} second`) + } + + const remainingTimeout = timeout - startDuration + + await new Promise((resolve, reject) => { + const stopWatch = xapi.watchObject(restoredVm.$ref, vm => { + if (vm.$guest_metrics) { + stopWatch() + timeoutId !== undefined && clearTimeout(timeoutId) + resolve() + } + }) + + const timeoutId = setTimeout(() => { + stopWatch() + reject(new Error(`Guest tools of VM ${restoredId} not started after ${timeout / 1000} second`)) + }, remainingTimeout) + }) + } finally { + restoredVm !== undefined && xapi !== undefined && (await xapi.VM_destroy(restoredVm.$ref)) + } + } } diff --git a/packages/xo-web/src/common/intl/messages.js b/packages/xo-web/src/common/intl/messages.js index ddd5fbfa430..d80cae89ad7 100644 --- a/packages/xo-web/src/common/intl/messages.js +++ b/packages/xo-web/src/common/intl/messages.js @@ -481,6 +481,7 @@ const messages = { 'If your country participates in DST, it is advised that you avoid scheduling jobs at the time of change. e.g. 2AM to 3AM for US.', // ------ New backup ----- + checkBackup: 'Restore health check', newBackupAdvancedSettings: 'Advanced settings', newBackupSettings: 'Settings', reportWhenAlways: 'Always', @@ -1631,6 +1632,7 @@ const messages = { refreshBackupList: 'Refresh backup list', restoreVmBackups: 'Restore', restoreVmBackupsTitle: 'Restore {vm}', + checkVmBackupsTitle: 'Restore health check {vm}', restoreVmBackupsBulkTitle: 'Restore {nVms, number} VM{nVms, plural, one {} other {s}}', restoreVmBackupsBulkMessage: 'Restore {nVms, number} VM{nVms, plural, one {} other {s}} from {nVms, plural, one {its} other {their}} {oldestOrLatest} backup.', diff --git a/packages/xo-web/src/common/xo/index.js b/packages/xo-web/src/common/xo/index.js index f53d255ba62..acdac9070d6 100644 --- a/packages/xo-web/src/common/xo/index.js +++ b/packages/xo-web/src/common/xo/index.js @@ -2237,6 +2237,14 @@ export const restoreBackup = ( return promise } +export const checkBackup = (backup, sr, { mapVdisSrs = {} } = {}) => { + return _call('backupNg.checkBackup', { + id: resolveId(backup), + settings: { mapVdisSrs: resolveIds(mapVdisSrs) }, + sr: resolveId(sr), + }) +} + export const deleteBackup = backup => _call('backupNg.deleteVmBackup', { id: resolveId(backup) }) export const deleteBackups = async backups => diff --git a/packages/xo-web/src/icons.scss b/packages/xo-web/src/icons.scss index b0c2940d06e..d702e8ba32b 100644 --- a/packages/xo-web/src/icons.scss +++ b/packages/xo-web/src/icons.scss @@ -345,6 +345,10 @@ @extend .fa; @extend .fa-download; } + &-check { + @extend .fa; + @extend .fa-check; + } &-restore { @extend .fa; @extend .fa-upload; diff --git a/packages/xo-web/src/xo-app/backup/restore/index.js b/packages/xo-web/src/xo-app/backup/restore/index.js index d8b8cb60171..69c7f514ea1 100644 --- a/packages/xo-web/src/xo-app/backup/restore/index.js +++ b/packages/xo-web/src/xo-app/backup/restore/index.js @@ -10,7 +10,7 @@ import { confirm } from 'modal' import { error } from 'notification' import { FormattedDate } from 'react-intl' import { cloneDeep, filter, find, flatMap, forEach, map, reduce, orderBy } from 'lodash' -import { deleteBackups, listVmBackups, restoreBackup, subscribeBackupNgJobs, subscribeRemotes } from 'xo' +import { checkBackup, deleteBackups, listVmBackups, restoreBackup, subscribeBackupNgJobs, subscribeRemotes } from 'xo' import RestoreBackupsModalBody, { RestoreBackupsBulkModalBody } from './restore-backups-modal-body' import DeleteBackupsModalBody from './delete-backups-modal-body' @@ -94,7 +94,7 @@ export default class Restore extends Component { backupDataByVm: {}, } - componentWillReceiveProps(props) { + UNSAFE_componentWillReceiveProps(props) { if (props.remotes !== this.props.remotes || props.jobs !== this.props.jobs) { this._refreshBackupList(props.remotes, props.jobs) } @@ -198,6 +198,24 @@ export default class Restore extends Component { }, noop) .then(() => this._refreshBackupList()) + _restoreHealthCheck = data => + confirm({ + title: _('checkVmBackupsTitle', { vm: data.last.vm.name_label }), + body: , + icon: 'restore', + }) + .then(({ backup, targetSrs: { mainSr, mapVdisSrs } }) => { + if (backup == null || mainSr == null) { + error(_('backupRestoreErrorTitle'), _('backupRestoreErrorMessage')) + return + } + + return checkBackup(backup, mainSr, { + mapVdisSrs, + }) + }, noop) + .then(() => this._refreshBackupList()) + _delete = data => confirm({ title: _('deleteVmBackupsTitle', { vm: data.last.vm.name_label }), @@ -251,6 +269,12 @@ export default class Restore extends Component { label: _('restoreVmBackups'), level: 'primary', }, + { + icon: 'check', + individualHandler: this._restoreHealthCheck, + label: _('checkBackup'), + level: 'secondary', + }, { handler: this._bulkDelete, icon: 'delete', diff --git a/packages/xo-web/src/xo-app/backup/restore/restore-backups-modal-body.js b/packages/xo-web/src/xo-app/backup/restore/restore-backups-modal-body.js index d2d8fbcc393..0dc77496cae 100644 --- a/packages/xo-web/src/xo-app/backup/restore/restore-backups-modal-body.js +++ b/packages/xo-web/src/xo-app/backup/restore/restore-backups-modal-body.js @@ -53,23 +53,31 @@ export default class RestoreBackupsModalBody extends Component { vdis={this._getDisks()} /> -
- {_('restoreVmBackupsStart', { nVms: 1 })} -
-
- {' '} - {_('generateNewMacAddress')} -
+ {this.props.showStartAfterBackup && ( +
+ {_('restoreVmBackupsStart', { nVms: 1 })} +
+ )} + {this.props.showGenerateNewMacAddress && ( +
+ {' '} + {_('generateNewMacAddress')} +
+ )} )} ) } } +RestoreBackupsModalBody.defaultProps = { + showGenerateNewMacAddress: true, + showStartAfterBackup: true, +} export class RestoreBackupsBulkModalBody extends Component { state = { generateNewMacAddresses: false, latest: true }