feat(xo-server,xo-web/backups): restore health check (#6148)

This commit is contained in:
Florent BEAUCHAMP
2022-04-21 10:26:36 +02:00
committed by GitHub
parent c024346475
commit 7d6e832226
8 changed files with 120 additions and 13 deletions

View File

@@ -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

View File

@@ -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 }) {

View File

@@ -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))
}
}
}

View File

@@ -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.',

View File

@@ -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 =>

View File

@@ -345,6 +345,10 @@
@extend .fa;
@extend .fa-download;
}
&-check {
@extend .fa;
@extend .fa-check;
}
&-restore {
@extend .fa;
@extend .fa-upload;

View File

@@ -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: <RestoreBackupsModalBody data={data} showGenerateNewMacAddress={false} showStartAfterBackup={false} />,
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',

View File

@@ -53,23 +53,31 @@ export default class RestoreBackupsModalBody extends Component {
vdis={this._getDisks()}
/>
</div>
<div>
<Toggle iconSize={1} onChange={this.linkState('start')} /> {_('restoreVmBackupsStart', { nVms: 1 })}
</div>
<div>
<Toggle
iconSize={1}
value={this.state.generateNewMacAddresses}
onChange={this.toggleState('generateNewMacAddresses')}
/>{' '}
{_('generateNewMacAddress')}
</div>
{this.props.showStartAfterBackup && (
<div>
<Toggle iconSize={1} onChange={this.linkState('start')} /> {_('restoreVmBackupsStart', { nVms: 1 })}
</div>
)}
{this.props.showGenerateNewMacAddress && (
<div>
<Toggle
iconSize={1}
value={this.state.generateNewMacAddresses}
onChange={this.toggleState('generateNewMacAddresses')}
/>{' '}
{_('generateNewMacAddress')}
</div>
)}
</div>
)}
</div>
)
}
}
RestoreBackupsModalBody.defaultProps = {
showGenerateNewMacAddress: true,
showStartAfterBackup: true,
}
export class RestoreBackupsBulkModalBody extends Component {
state = { generateNewMacAddresses: false, latest: true }