Skip to content

Commit

Permalink
feat(xo-server,xo-web/backups): restore health check (#6148)
Browse files Browse the repository at this point in the history
  • Loading branch information
fbeauchamp authored Apr 21, 2022
1 parent c024346 commit 7d6e832
Show file tree
Hide file tree
Showing 8 changed files with 120 additions and 13 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions packages/xo-server/src/api/backup-ng.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
42 changes: 42 additions & 0 deletions packages/xo-server/src/xo-mixins/backups-ng/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
}
2 changes: 2 additions & 0 deletions packages/xo-web/src/common/intl/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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.',
Expand Down
8 changes: 8 additions & 0 deletions packages/xo-web/src/common/xo/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
4 changes: 4 additions & 0 deletions packages/xo-web/src/icons.scss
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,10 @@
@extend .fa;
@extend .fa-download;
}
&-check {
@extend .fa;
@extend .fa-check;
}
&-restore {
@extend .fa;
@extend .fa-upload;
Expand Down
28 changes: 26 additions & 2 deletions packages/xo-web/src/xo-app/backup/restore/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 }),
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down

0 comments on commit 7d6e832

Please sign in to comment.