From efffbafa426ca426d5dc06ebef38d42e4612878a Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Mon, 30 Mar 2020 22:02:57 +0200 Subject: [PATCH] feat(xo-server,xo-web): checkpoint backups (#4252) Fixes #645 --- CHANGELOG.unreleased.md | 2 + packages/xo-server/src/xapi/index.js | 185 ++++++++++++------ packages/xo-server/src/xapi/mixins/vm.js | 8 +- .../src/xo-mixins/backups-ng/index.js | 28 ++- packages/xo-web/src/common/intl/messages.js | 6 +- .../backup/_getSettingsWithNonDefaultValue.js | 1 + .../xo-app/backup/new/_selectSnapshotMode.js | 73 +++++++ .../xo-web/src/xo-app/backup/new/index.js | 65 +++--- .../src/xo-app/backup/overview/tab-jobs.js | 9 + 9 files changed, 263 insertions(+), 114 deletions(-) create mode 100644 packages/xo-web/src/xo-app/backup/new/_selectSnapshotMode.js diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 9158cd734..a1d9b851c 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -7,6 +7,8 @@ > Users must be able to say: “Nice enhancement, I'm eager to test it” +- [Backup] **BETA** Ability to backup running VMs with their memory [#645](https://github.com/vatesfr/xen-orchestra/issues/645) (PR [#4252](https://github.com/vatesfr/xen-orchestra/pull/4252)) + ### Bug fixes > Users must be able to say: “I had this issue, happy to know it's fixed” diff --git a/packages/xo-server/src/xapi/index.js b/packages/xo-server/src/xapi/index.js index b6bcc148c..0da6c677d 100644 --- a/packages/xo-server/src/xapi/index.js +++ b/packages/xo-server/src/xapi/index.js @@ -28,6 +28,7 @@ import { flatMap, flatten, groupBy, + identity, includes, isEmpty, noop, @@ -502,51 +503,63 @@ export default class Xapi extends XapiBase { } // Low level create VM. - _createVmRecord({ - actions_after_crash, - actions_after_reboot, - actions_after_shutdown, - affinity, - // appliance, - blocked_operations, - generation_id, - ha_always_run, - ha_restart_priority, - has_vendor_device = false, // Avoid issue with some Dundee builds. - hardware_platform_version, - HVM_boot_params, - HVM_boot_policy, - HVM_shadow_multiplier, - is_a_template, - memory_dynamic_max, - memory_dynamic_min, - memory_static_max, - memory_static_min, - name_description, - name_label, - order, - other_config, - PCI_bus, - platform, - protection_policy, - PV_args, - PV_bootloader, - PV_bootloader_args, - PV_kernel, - PV_legacy_args, - PV_ramdisk, - recommendations, - shutdown_delay, - start_delay, - // suspend_SR, - tags, - user_version, - VCPUs_at_startup, - VCPUs_max, - VCPUs_params, - version, - xenstore_data, - }) { + _createVmRecord( + { + actions_after_crash, + actions_after_reboot, + actions_after_shutdown, + affinity, + // appliance, + blocked_operations, + domain_type, // Used when the VM is created Suspended + generation_id, + ha_always_run, + ha_restart_priority, + has_vendor_device = false, // Avoid issue with some Dundee builds. + hardware_platform_version, + HVM_boot_params, + HVM_boot_policy, + HVM_shadow_multiplier, + is_a_template, + last_boot_CPU_flags, // Used when the VM is created Suspended + last_booted_record, // Used when the VM is created Suspended + memory_dynamic_max, + memory_dynamic_min, + memory_static_max, + memory_static_min, + name_description, + name_label, + order, + other_config, + PCI_bus, + platform, + protection_policy, + PV_args, + PV_bootloader, + PV_bootloader_args, + PV_kernel, + PV_legacy_args, + PV_ramdisk, + recommendations, + shutdown_delay, + start_delay, + // suspend_SR, + tags, + user_version, + VCPUs_at_startup, + VCPUs_max, + VCPUs_params, + version, + xenstore_data, + }, + { + // if set, will create the VM in Suspended power_state with this VDI + // + // it's a separate param because it's not supported for all versions of + // XCP-ng/XenServer and should be passed explicitly + suspend_VDI, + } = {} + ) { log.debug(`Creating VM ${name_label}`) return this.call( @@ -598,6 +611,13 @@ export default class Xapi extends XapiBase { tags, version: asInteger(version), xenstore_data, + + // VM created Suspended + power_state: suspend_VDI !== undefined ? 'Suspended' : undefined, + suspend_VDI, + domain_type, + last_boot_CPU_flags, + last_booted_record, }) ) } @@ -894,6 +914,17 @@ export default class Xapi extends XapiBase { this._exportVdi($cancelToken, vdi, baseVdi, VDI_FORMAT_VHD) }) + const suspendVdi = vm.$suspend_VDI + if (suspendVdi !== undefined) { + const vdiRef = suspendVdi.$ref + vdis[vdiRef] = { + ...suspendVdi, + $SR$uuid: suspendVdi.$SR.uuid, + } + streams[`${vdiRef}.vhd`] = () => + this._exportVdi($cancelToken, suspendVdi, undefined, VDI_FORMAT_VHD) + } + const vifs = {} forEach(vm.$VIFs, vif => { const network = vif.$network @@ -980,23 +1011,42 @@ export default class Xapi extends XapiBase { } }) + // 0. Create suspend_VDI + let suspendVdi + if (delta.vm.power_state === 'Suspended') { + const vdi = delta.vdis[delta.vm.suspend_VDI] + suspendVdi = await this.createVdi({ + ...vdi, + other_config: { + ...vdi.other_config, + [TAG_BASE_DELTA]: undefined, + [TAG_COPY_SRC]: vdi.uuid, + }, + sr: mapVdisSrs[vdi.uuid] || srId, + }) + $defer.onFailure.call(this, '_deleteVdi', suspendVdi.$ref) + } + // 1. Create the VMs. const vm = await this._getOrWaitObject( - await this._createVmRecord({ - ...delta.vm, - affinity: null, - blocked_operations: { - ...delta.vm.blocked_operations, - start: 'Importing…', + await this._createVmRecord( + { + ...delta.vm, + affinity: null, + blocked_operations: { + ...delta.vm.blocked_operations, + start: 'Importing…', + }, + ha_always_run: false, + is_a_template: false, + name_label: `[Importing…] ${name_label}`, + other_config: { + ...delta.vm.other_config, + [TAG_COPY_SRC]: delta.vm.uuid, + }, }, - ha_always_run: false, - is_a_template: false, - name_label: `[Importing…] ${name_label}`, - other_config: { - ...delta.vm.other_config, - [TAG_COPY_SRC]: delta.vm.uuid, - }, - }) + { suspend_VDI: suspendVdi?.$ref } + ) ) $defer.onFailure(() => this._deleteVm(vm)) @@ -1004,8 +1054,10 @@ export default class Xapi extends XapiBase { await asyncMap(vm.$VBDs, vbd => this._deleteVbd(vbd))::ignoreErrors() // 3. Create VDIs & VBDs. + // + // TODO: move all VDIs creation before the VM and simplify the code const vbds = groupBy(delta.vbds, 'VDI') - const newVdis = await map(delta.vdis, async (vdi, vdiId) => { + const newVdis = await map(delta.vdis, async (vdi, vdiRef) => { let newVdi const remoteBaseVdiUuid = detectBase && vdi.other_config[TAG_BASE_DELTA] @@ -1022,6 +1074,9 @@ export default class Xapi extends XapiBase { $defer.onFailure(() => this._deleteVdi(newVdi.$ref)) await newVdi.update_other_config(TAG_COPY_SRC, vdi.uuid) + } else if (vdiRef === delta.vm.suspend_VDI) { + // suspend VDI has been already created + newVdi = suspendVdi } else { newVdi = await this.createVdi({ ...vdi, @@ -1035,7 +1090,7 @@ export default class Xapi extends XapiBase { $defer.onFailure(() => this._deleteVdi(newVdi.$ref)) } - await asyncMap(vbds[vdiId], vbd => + await asyncMap(vbds[vdiRef], vbd => this.createVbd({ ...vbd, vdi: newVdi, @@ -1653,6 +1708,8 @@ export default class Xapi extends XapiBase { async createVbd({ bootable = false, + currently_attached = false, + device = '', other_config = {}, qos_algorithm_params = {}, qos_algorithm_type = '', @@ -1693,9 +1750,13 @@ export default class Xapi extends XapiBase { } } + const ifVmSuspended = vm.power_state === 'Suspended' ? identity : noop + // By default a VBD is unpluggable. const vbdRef = await this.call('VBD.create', { bootable: Boolean(bootable), + currently_attached: ifVmSuspended(currently_attached), + device: ifVmSuspended(device), empty: Boolean(empty), mode, other_config, @@ -2045,6 +2106,8 @@ export default class Xapi extends XapiBase { const vifRef = await this.call( 'VIF.create', filterUndefineds({ + currently_attached: + vm.power_state === 'Suspended' ? currently_attached : undefined, device, ipv4_allowed, ipv6_allowed, diff --git a/packages/xo-server/src/xapi/mixins/vm.js b/packages/xo-server/src/xapi/mixins/vm.js index 64f6c66a1..e22b3763e 100644 --- a/packages/xo-server/src/xapi/mixins/vm.js +++ b/packages/xo-server/src/xapi/mixins/vm.js @@ -1,6 +1,6 @@ import deferrable from 'golike-defer' import { find, gte, includes, isEmpty, lte, noop } from 'lodash' -import { ignoreErrors, pCatch } from 'promise-toolbox' +import { cancelable, ignoreErrors, pCatch } from 'promise-toolbox' import { NULL_REF } from 'xen-api' import { forEach, mapToArray, parseSize } from '../../utils' @@ -18,10 +18,12 @@ const XEN_VIDEORAM_VALUES = [1, 2, 4, 8, 16] export default { // https://xapi-project.github.io/xen-api/classes/vm.html#checkpoint - async checkpointVm(vmId, nameLabel) { + @cancelable + async checkpointVm($cancelToken, vmId, nameLabel) { const vm = this.getObject(vmId) try { const ref = await this.callAsync( + $cancelToken, 'VM.checkpoint', vm.$ref, nameLabel != null ? nameLabel : vm.name_label @@ -29,7 +31,7 @@ export default { return this.barrier(ref) } catch (error) { if (error.code === 'VM_BAD_POWER_STATE') { - return this._snapshotVm(vm, nameLabel) + return this._snapshotVm($cancelToken, vm, nameLabel) } throw error } diff --git a/packages/xo-server/src/xo-mixins/backups-ng/index.js b/packages/xo-server/src/xo-mixins/backups-ng/index.js index bc1c24a3d..f74a04e71 100644 --- a/packages/xo-server/src/xo-mixins/backups-ng/index.js +++ b/packages/xo-server/src/xo-mixins/backups-ng/index.js @@ -74,6 +74,7 @@ export type ReportWhen = 'always' | 'failure' | 'never' type Settings = {| bypassVdiChainsCheck?: boolean, + checkpointSnapshot?: boolean, concurrency?: number, deleteFirst?: boolean, copyRetention?: number, @@ -149,6 +150,7 @@ const getOldEntries = (retention: number, entries?: T[]): T[] => const defaultSettings: Settings = { bypassVdiChainsCheck: false, + checkpointSnapshot: false, concurrency: 0, deleteFirst: false, exportRetention: 0, @@ -1208,6 +1210,9 @@ export default class BackupNg { ) } + const checkpointSnapshot = + !offlineSnapshot && + getSetting(settings, 'checkpointSnapshot', [vmUuid, '']) exported = (await wrapTask( { logger, @@ -1215,11 +1220,17 @@ export default class BackupNg { parentId: taskId, result: _ => _.uuid, }, - xapi._snapshotVm( - $cancelToken, - vm, - `[XO Backup ${job.name}] ${vm.name_label}` - ) + checkpointSnapshot + ? xapi.checkpointVm( + $cancelToken, + vm.$id, + `[XO Backup ${job.name}] ${vm.name_label}` + ) + : xapi._snapshotVm( + $cancelToken, + vm, + `[XO Backup ${job.name}] ${vm.name_label}` + ) ): any) if (startAfterSnapshot) { @@ -1641,7 +1652,12 @@ export default class BackupNg { deltaExport.vdis, vdi => `vdis/${jobId}/${ - (xapi.getObject(vdi.snapshot_of): Object).uuid + (vdi.type === 'suspend' + ? // doesn't make sense to group by parent for memory because we + // don't do delta for it + vdi + : (xapi.getObject(vdi.snapshot_of): Object) + ).uuid }/${basename}.vhd` ), vm, diff --git a/packages/xo-web/src/common/intl/messages.js b/packages/xo-web/src/common/intl/messages.js index 763562b2d..f3597a825 100644 --- a/packages/xo-web/src/common/intl/messages.js +++ b/packages/xo-web/src/common/intl/messages.js @@ -72,6 +72,10 @@ const messages = { altered: 'Altered', missing: 'Missing', verified: 'Verified', + snapshotMode: 'Snapshot mode', + normal: 'Normal', + withMemory: 'With memory', + offline: 'Offline', // ----- Modals ----- alertOk: 'OK', @@ -479,8 +483,8 @@ const messages = { smartBackup: 'Smart backup', snapshotRetention: 'Snapshot retention', backupName: 'Name', + checkpointSnapshot: 'Checkpoint snapshot', offlineSnapshot: 'Offline snapshot', - offlineSnapshotInfo: 'Shutdown VMs before snapshotting them', offlineBackup: 'Offline backup', offlineBackupInfo: 'Export VMs without snapshotting them. The VMs will be shutdown during the export.', diff --git a/packages/xo-web/src/xo-app/backup/_getSettingsWithNonDefaultValue.js b/packages/xo-web/src/xo-app/backup/_getSettingsWithNonDefaultValue.js index c1b6211e6..c5aaf5cd2 100644 --- a/packages/xo-web/src/xo-app/backup/_getSettingsWithNonDefaultValue.js +++ b/packages/xo-web/src/xo-app/backup/_getSettingsWithNonDefaultValue.js @@ -3,6 +3,7 @@ import { pickBy } from 'lodash' const DEFAULTS = { __proto__: null, + checkpointSnapshot: false, compression: '', concurrency: 0, fullInterval: 0, diff --git a/packages/xo-web/src/xo-app/backup/new/_selectSnapshotMode.js b/packages/xo-web/src/xo-app/backup/new/_selectSnapshotMode.js new file mode 100644 index 000000000..458c1d7af --- /dev/null +++ b/packages/xo-web/src/xo-app/backup/new/_selectSnapshotMode.js @@ -0,0 +1,73 @@ +import _ from 'intl' +import decorate from 'apply-decorators' +import PropTypes from 'prop-types' +import React from 'react' +import { CURRENT, PREMIUM } from 'xoa-plans' +import { generateId } from 'reaclette-utils' +import { injectState, provideState } from 'reaclette' +import { Select } from 'form' + +import { FormGroup } from '../utils' + +const OPTIONS = [ + { + label: _('normal'), + value: '', + }, + { + disabled: CURRENT.value < PREMIUM.value, + label: _('withMemory'), + value: 'checkpointSnapshot', + }, + { + label: _('offline'), + value: 'offlineSnapshot', + }, +] + +const SelectSnapshotMode = decorate([ + provideState({ + effects: { + setMode(_, value) { + this.props.setGlobalSettings({ + offlineSnapshot: value === 'offlineSnapshot', + checkpointSnapshot: value === 'checkpointSnapshot', + }) + }, + }, + computed: { + idSelect: generateId, + value: (_, { checkpointSnapshot, offlineSnapshot }) => + checkpointSnapshot + ? 'checkpointSnapshot' + : offlineSnapshot + ? 'offlineSnapshot' + : '', + }, + }), + injectState, + ({ state, effects, ...props }) => ( + + {' '} + @@ -1157,21 +1145,12 @@ export default decorate([ )} - {!state.offlineBackupActive && ( - - - - )} + )} diff --git a/packages/xo-web/src/xo-app/backup/overview/tab-jobs.js b/packages/xo-web/src/xo-app/backup/overview/tab-jobs.js index 85e3086ba..380078c7a 100644 --- a/packages/xo-web/src/xo-app/backup/overview/tab-jobs.js +++ b/packages/xo-web/src/xo-app/backup/overview/tab-jobs.js @@ -247,6 +247,7 @@ class JobsTable extends React.Component { { itemRenderer: job => { const { + checkpointSnapshot, compression, concurrency, fullInterval, @@ -294,6 +295,14 @@ class JobsTable extends React.Component { )} )} + {checkpointSnapshot !== undefined && ( +
  • + {_.keyValue( + _('checkpointSnapshot'), + _(checkpointSnapshot ? 'stateEnabled' : 'stateDisabled') + )} +
  • + )} {compression !== undefined && (
  • {_.keyValue(