@xen-orchestra/backups 0.69.4 → 0.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -255,6 +255,14 @@ export class ImportVmBackup {
255
255
  : await importIncrementalVm(backup, await xapi.getRecord('SR', srRef), {
256
256
  newMacAddresses,
257
257
  })
258
+ let size = 0
259
+ if (isFull) {
260
+ size = sizeContainer.size
261
+ } else {
262
+ for (const disk of Object.values(backup.disks)) {
263
+ size += disk.getNbGeneratedBlock() * disk.getBlockSize()
264
+ }
265
+ }
258
266
  const remoteName = adapter._handler._remote.name
259
267
  let desc = `Restored on ${formatFilenameDate(+new Date())}`
260
268
  if (remoteName !== undefined) {
@@ -275,7 +283,7 @@ export class ImportVmBackup {
275
283
  ])
276
284
 
277
285
  return {
278
- size: sizeContainer.size,
286
+ size,
279
287
  id: await xapi.getField('VM', vmRef, 'uuid'),
280
288
  }
281
289
  }
@@ -8,7 +8,7 @@ import { defer } from 'golike-defer'
8
8
  import { cancelableMap } from './_cancelableMap.mjs'
9
9
  import { Task } from './Task.mjs'
10
10
  import pick from 'lodash/pick.js'
11
- import { BASE_DELTA_VDI, COPY_OF, VM_UUID } from './_otherConfig.mjs'
11
+ import { BASE_DELTA_VDI, CONTENT_KEY, COPY_OF, VM_UUID } from './_otherConfig.mjs'
12
12
 
13
13
  import { VHD_MAX_SIZE, XapiDiskSource } from '@xen-orchestra/xapi'
14
14
  import { toVhdStream } from 'vhd-lib/disk-consumer/index.mjs'
@@ -124,7 +124,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
124
124
  $defer,
125
125
  incrementalVm,
126
126
  sr,
127
- { cancelToken = CancelToken.none, newMacAddresses = false } = {}
127
+ { cancelToken = CancelToken.none, newMacAddresses = false, targetRef = undefined } = {}
128
128
  ) {
129
129
  const { version } = incrementalVm
130
130
  if (compareVersions(version, '1.0.0') < 0) {
@@ -136,44 +136,106 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
136
136
 
137
137
  const vdiRecords = incrementalVm.vdis
138
138
 
139
- // 0. Create suspend_VDI
139
+ // When targetRef is provided, update the existing VM instead of creating a new one.
140
+ const targetVm = targetRef !== undefined ? xapi.getObjectByRef(targetRef, undefined) : undefined
141
+ const isUpdate = targetVm !== undefined && !targetVm.is_a_snapshot && !targetVm.is_a_template
142
+
143
+ // 0. Create suspend_VDI (only when creating a new VM).
140
144
  let suspendVdi
141
- if (vmRecord.suspend_VDI !== undefined && vmRecord.suspend_VDI !== 'OpaqueRef:NULL') {
142
- const vdi = vdiRecords[vmRecord.suspend_VDI]
143
- if (vdi === undefined) {
144
- Task.warning('Suspend VDI not available for this suspended VM', {
145
- vm: pick(vmRecord, 'uuid', 'name_label', 'suspend_VDI'),
146
- })
147
- } else {
148
- suspendVdi = await xapi.getRecord('VDI', await xapi.VDI_create(vdi))
149
- $defer.onFailure(() => suspendVdi.$destroy())
145
+ if (!isUpdate) {
146
+ if (vmRecord.suspend_VDI !== undefined && vmRecord.suspend_VDI !== 'OpaqueRef:NULL') {
147
+ const vdi = vdiRecords[vmRecord.suspend_VDI]
148
+ if (vdi === undefined) {
149
+ Task.warning('Suspend VDI not available for this suspended VM', {
150
+ vm: pick(vmRecord, 'uuid', 'name_label', 'suspend_VDI'),
151
+ })
152
+ } else {
153
+ suspendVdi = await xapi.getRecord('VDI', await xapi.VDI_create(vdi))
154
+ $defer.onFailure(() => suspendVdi.$destroy())
155
+ }
150
156
  }
151
157
  }
152
158
 
153
- // 1. Create the VM.
154
- const vmRef = await xapi.VM_create(
155
- {
156
- ...vmRecord,
157
- affinity: undefined,
158
- blocked_operations: {
159
- ...vmRecord.blocked_operations,
159
+ // 1. Create the VM or update the existing one.
160
+ let vmRef
161
+ if (isUpdate) {
162
+ vmRef = targetRef
163
+ await Promise.all([
164
+ xapi.setFields('VM', vmRef, {
165
+ actions_after_crash: vmRecord.actions_after_crash,
166
+ actions_after_reboot: vmRecord.actions_after_reboot,
167
+ actions_after_shutdown: vmRecord.actions_after_shutdown,
168
+ domain_type: vmRecord.domain_type,
169
+ ha_restart_priority: vmRecord.ha_restart_priority,
170
+ has_vendor_device: vmRecord.has_vendor_device,
171
+ HVM_boot_params: vmRecord.HVM_boot_params,
172
+ HVM_boot_policy: vmRecord.HVM_boot_policy,
173
+ HVM_shadow_multiplier: vmRecord.HVM_shadow_multiplier,
174
+ memory_dynamic_max: vmRecord.memory_dynamic_max,
175
+ memory_dynamic_min: vmRecord.memory_dynamic_min,
176
+ memory_static_max: vmRecord.memory_static_max,
177
+ memory_static_min: vmRecord.memory_static_min,
178
+ name_label: vmRecord.name_label,
179
+ name_description: vmRecord.name_description,
180
+ order: vmRecord.order,
181
+ other_config: vmRecord.other_config,
182
+ platform: vmRecord.platform,
183
+ PV_args: vmRecord.PV_args,
184
+ PV_bootloader: vmRecord.PV_bootloader,
185
+ PV_bootloader_args: vmRecord.PV_bootloader_args,
186
+ PV_kernel: vmRecord.PV_kernel,
187
+ PV_legacy_args: vmRecord.PV_legacy_args,
188
+ PV_ramdisk: vmRecord.PV_ramdisk,
189
+ recommendations: vmRecord.recommendations,
190
+ shutdown_delay: vmRecord.shutdown_delay,
191
+ start_delay: vmRecord.start_delay,
192
+ suspend_SR: vmRecord.suspend_SR,
193
+ tags: vmRecord.tags,
194
+ user_version: vmRecord.user_version,
195
+ VCPUs_at_startup: vmRecord.VCPUs_at_startup,
196
+ VCPUs_max: vmRecord.VCPUs_max,
197
+ VCPUs_params: vmRecord.VCPUs_params,
198
+ xenstore_data: vmRecord.xenstore_data,
199
+ }),
200
+ targetVm.update_blocked_operations({
160
201
  start: 'Importing…',
161
202
  start_on: 'Importing…',
203
+ }),
204
+ ])
205
+ } else {
206
+ vmRef = await xapi.VM_create(
207
+ {
208
+ ...vmRecord,
209
+ affinity: undefined,
210
+ blocked_operations: {
211
+ ...vmRecord.blocked_operations,
212
+ start: 'Importing…',
213
+ start_on: 'Importing…',
214
+ },
215
+ ha_always_run: false,
216
+ is_a_template: false,
217
+ name_label: '[Importing…] ' + vmRecord.name_label,
162
218
  },
163
- ha_always_run: false,
164
- is_a_template: false,
165
- name_label: '[Importing…] ' + vmRecord.name_label,
166
- },
167
- {
168
- bios_strings: vmRecord.bios_strings,
169
- generateMacSeed: newMacAddresses,
170
- suspend_VDI: suspendVdi?.$ref,
171
- }
172
- )
173
- $defer.onFailure.call(xapi, 'VM_destroy', vmRef)
219
+ {
220
+ bios_strings: vmRecord.bios_strings,
221
+ generateMacSeed: newMacAddresses,
222
+ suspend_VDI: suspendVdi?.$ref,
223
+ }
224
+ )
225
+ $defer.onFailure.call(xapi, 'VM_destroy', vmRef)
226
+ }
174
227
 
175
228
  // 2. Delete all VBDs which may have been created by the import.
176
- await asyncMap(await xapi.getField('VM', vmRef, 'VBDs'), ref => ignoreErrors.call(xapi.call('VBD.destroy', ref)))
229
+ // In update mode, also collect the VDI refs so orphaned VDIs can be destroyed after new ones are created.
230
+ const existingVbdRefs = await xapi.getField('VM', vmRef, 'VBDs')
231
+ const oldVdiRefs = new Set(
232
+ isUpdate
233
+ ? (await asyncMap(existingVbdRefs, ref => xapi.getField('VBD', ref, 'VDI'))).filter(
234
+ ref => ref !== undefined && ref !== 'OpaqueRef:NULL'
235
+ )
236
+ : []
237
+ )
238
+ await asyncMap(existingVbdRefs, ref => ignoreErrors.call(xapi.call('VBD.destroy', ref)))
177
239
 
178
240
  // 3. Create VDIs & VBDs.
179
241
  const vbdRecords = incrementalVm.vbds
@@ -184,10 +246,18 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
184
246
  let newVdi
185
247
 
186
248
  if (vdi.baseVdi?.$ref !== undefined) {
187
- newVdi = await xapi.getRecord('VDI', await xapi.VDI_clone(vdi.baseVdi.$ref))
188
- $defer.onFailure(() => newVdi.$destroy())
189
-
249
+ if (isUpdate) {
250
+ // In update mode, reuse the existing target VDI directly — no clone needed.
251
+ newVdi = vdi.baseVdi
252
+ oldVdiRefs.delete(newVdi.$ref)
253
+ } else {
254
+ newVdi = await xapi.getRecord('VDI', await xapi.VDI_clone(vdi.baseVdi.$ref))
255
+ $defer.onFailure(() => newVdi.$destroy())
256
+ }
190
257
  await newVdi.update_other_config(COPY_OF, vdi.uuid)
258
+ if (vdi.other_config[CONTENT_KEY] !== undefined) {
259
+ await newVdi.update_other_config(CONTENT_KEY, vdi.other_config[CONTENT_KEY])
260
+ }
191
261
  if (vdi.virtual_size > newVdi.virtual_size) {
192
262
  await newVdi.$callAsync('resize', vdi.virtual_size)
193
263
  }
@@ -213,6 +283,16 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
213
283
  newVdis[vdiRef] = newVdi
214
284
  })
215
285
 
286
+ // 3.5. Destroy old VDIs that are no longer attached to the VM.
287
+ // Uses ignoreErrors because some storage backends refuse to destroy a VDI that still has snapshot children;
288
+ // those VDIs will become truly orphaned once the old snapshot VMs are cleaned up by _deleteOldEntries.
289
+ await asyncMap([...oldVdiRefs], ref => ignoreErrors.call(xapi.call('VDI.destroy', ref)))
290
+
291
+ // 4. For updates, destroy existing VIFs before recreating them.
292
+ if (isUpdate) {
293
+ await asyncMap(await xapi.getField('VM', vmRef, 'VIFs'), ref => ignoreErrors.call(xapi.call('VIF.destroy', ref)))
294
+ }
295
+
216
296
  const networksByNameLabelByVlan = {}
217
297
  let defaultNetwork
218
298
  Object.values(xapi.objects.all).forEach(object => {
package/_otherConfig.mjs CHANGED
@@ -28,6 +28,11 @@ export const EXPORTED_SUCCESSFULLY = 'xo:backup:exported'
28
28
  // the VM ( not the snapshot) uuid
29
29
  export const VM_UUID = 'xo:backup:vm'
30
30
 
31
+ // in `other_config` of source snapshot VDIs and replicated target VDIs
32
+ // contains the UUID of the source snapshot VDI whose content this VDI represents
33
+ // allows direction-agnostic discovery of common base VDIs (e.g. for failback/reverse replication)
34
+ export const CONTENT_KEY = 'xo:backup:contentKey'
35
+
31
36
  async function listVdiRefs(xapi, vmRef) {
32
37
  return xapi.VM_getDisks(vmRef)
33
38
  }
@@ -80,6 +85,7 @@ export async function getVmDeltaChainLength(xapi, vmRef) {
80
85
  export function resetVmOtherConfig(xapi, vmRef) {
81
86
  return applyToVmAndVdis(xapi, vmRef, (type, ref) => {
82
87
  return xapi.setFieldEntries(type, ref, 'other_config', {
88
+ [COPY_OF]: null,
83
89
  [DATETIME]: null,
84
90
  [DELTA_CHAIN_LENGTH]: null,
85
91
  [EXPORTED_SUCCESSFULLY]: null,
@@ -131,3 +137,24 @@ export async function markExportSuccessfull(xapi, vmRef) {
131
137
  xapi.setFieldEntry(type, ref, 'other_config', EXPORTED_SUCCESSFULLY, 'true')
132
138
  )
133
139
  }
140
+
141
+ /**
142
+ * Set CONTENT_KEY on each VDI of a VM snapshot to its own UUID.
143
+ *
144
+ * This marks each snapshot VDI with a unique content identifier that can be
145
+ * propagated to replicated copies for direction-agnostic base VDI discovery
146
+ * (e.g. for failback / reverse replication).
147
+ *
148
+ * @param {Xapi} xapi
149
+ * @param {String} snapshotRef
150
+ * @returns {Promise}
151
+ */
152
+ export async function setVmSnapshotContentKeys(xapi, snapshotRef) {
153
+ return applyToVmAndVdis(xapi, snapshotRef, async (type, ref) => {
154
+ if (type !== 'VDI') {
155
+ return
156
+ }
157
+ const uuid = await xapi.getField(type, ref, 'uuid')
158
+ return xapi.setFieldEntry(type, ref, 'other_config', CONTENT_KEY, uuid)
159
+ })
160
+ }
@@ -135,16 +135,6 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
135
135
  const deltaChainLength = Math.max(
136
136
  ...lastExportedVdis.map(({ other_config }) => Number(other_config[DELTA_CHAIN_LENGTH] ?? 0))
137
137
  )
138
- const fullInterval = this._settings.fullInterval
139
- if (fullInterval !== 0 && fullInterval <= deltaChainLength + 1) {
140
- debug('not using base VM because fullInterval reached', {
141
- fullInterval,
142
- deltaChainLength,
143
- eq: fullInterval < deltaChainLength + 1,
144
- dc1: deltaChainLength + 1,
145
- })
146
- return
147
- }
148
138
 
149
139
  const srcVdis = keyBy(await xapi.getRecords('VDI', await this._vm.$getDisks()), '$ref')
150
140
 
@@ -173,6 +163,20 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
173
163
  debug('no base VM found')
174
164
  return
175
165
  }
166
+
167
+ // we do tafter checkbasevdis because we want the writer to know the target VM
168
+ // especially on replication when we alwayr update the same VM
169
+ const fullInterval = this._settings.fullInterval
170
+ if (fullInterval !== 0 && fullInterval <= deltaChainLength + 1) {
171
+ debug('not using base VM because fullInterval reached', {
172
+ fullInterval,
173
+ deltaChainLength,
174
+ eq: fullInterval < deltaChainLength + 1,
175
+ dc1: deltaChainLength + 1,
176
+ })
177
+ return
178
+ }
179
+
176
180
  baseUuidToSrcVdiUuid.forEach((srcVdiUuid, baseUuid) => {
177
181
  if (presentBaseVdis.has(baseUuid)) {
178
182
  debug('found base VDI', {
@@ -10,7 +10,16 @@ import { defer } from 'golike-defer'
10
10
  import { getOldEntries } from '../../_getOldEntries.mjs'
11
11
  import { Task } from '../../Task.mjs'
12
12
  import { Abstract } from './_Abstract.mjs'
13
- import { DATETIME, JOB_ID, SCHEDULE_ID, VM_UUID, resetVmOtherConfig, setVmOtherConfig } from '../../_otherConfig.mjs'
13
+ import {
14
+ COPY_OF,
15
+ DATETIME,
16
+ JOB_ID,
17
+ SCHEDULE_ID,
18
+ VM_UUID,
19
+ resetVmOtherConfig,
20
+ setVmOtherConfig,
21
+ setVmSnapshotContentKeys,
22
+ } from '../../_otherConfig.mjs'
14
23
 
15
24
  const { warn, info } = createLogger('xo:backups:AbstractXapi')
16
25
 
@@ -96,7 +105,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
96
105
  config,
97
106
  healthCheckSr,
98
107
  job,
99
- scheduleId: schedule.id,
108
+ schedule,
100
109
  vmUuid: vm.uuid,
101
110
  settings,
102
111
  })
@@ -114,7 +123,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
114
123
  config,
115
124
  healthCheckSr,
116
125
  job,
117
- scheduleId: schedule.id,
126
+ schedule,
118
127
  vmUuid: vm.uuid,
119
128
  remoteId,
120
129
  settings: targetSettings,
@@ -132,7 +141,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
132
141
  healthCheckSr,
133
142
  job,
134
143
  ReplicationWriter,
135
- scheduleId: schedule.id,
144
+ schedule,
136
145
  vmUuid: vm.uuid,
137
146
  srs,
138
147
  settings,
@@ -150,7 +159,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
150
159
  config,
151
160
  healthCheckSr,
152
161
  job,
153
- scheduleId: schedule.id,
162
+ schedule,
154
163
  vmUuid: vm.uuid,
155
164
  sr,
156
165
  settings: targetSettings,
@@ -165,11 +174,9 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
165
174
 
166
175
  // ensure the VM itself does not have any backup metadata which would be
167
176
  // copied on manual snapshots and interfere with the backup jobs
177
+
168
178
  async _cleanMetadata() {
169
- const vm = this._vm
170
- if (JOB_ID in vm.other_config) {
171
- await resetVmOtherConfig(this._xapi, vm.$ref)
172
- }
179
+ await resetVmOtherConfig(this._xapi, this._vm.$ref)
173
180
  }
174
181
 
175
182
  async _snapshot() {
@@ -205,6 +212,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
205
212
  scheduleId: this.scheduleId,
206
213
  vmUuid: vm.uuid,
207
214
  })
215
+ await setVmSnapshotContentKeys(xapi, snapshotRef)
208
216
  const snapshot = await xapi.getRecord('VM', snapshotRef)
209
217
  await snapshot.set_name_label(this._getSnapshotNameLabel(vm))
210
218
  // reload data to ensure it is up to date with the new name label
@@ -233,11 +241,16 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
233
241
  const xapi = this._xapi
234
242
 
235
243
  const vdiCandidates = {}
236
-
244
+ const vdiUuids = this._vm.$VBDs.map(({ VDI }) => VDI)
237
245
  Object.values(xapi.objects.indexes.type.VDI)
238
246
  .filter(_ => !!_) // filter nullish
239
- .filter(({ other_config, $snapshot_of }) => {
240
- return $snapshot_of !== undefined && other_config[JOB_ID] === jobId && other_config[VM_UUID] === this._vm.uuid
247
+ .filter(({ other_config, snapshot_of }) => {
248
+ return (
249
+ vdiUuids.includes(snapshot_of) &&
250
+ other_config[JOB_ID] === jobId &&
251
+ other_config[VM_UUID] === this._vm.uuid &&
252
+ other_config[COPY_OF] === undefined
253
+ )
241
254
  })
242
255
  .forEach(vdi => {
243
256
  vdiCandidates[vdi.uuid] = vdi
@@ -27,7 +27,7 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
27
27
  async _run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
28
28
  const settings = this._settings
29
29
  const job = this._job
30
- const scheduleId = this._scheduleId
30
+ const scheduleId = this._schedule.id
31
31
 
32
32
  const adapter = this._adapter
33
33
  let metadata = await this._isAlreadyTransferred(timestamp)
@@ -34,7 +34,7 @@ export class FullXapiWriter extends MixinXapiWriter(AbstractFullWriter) {
34
34
  const sr = this._sr
35
35
  const settings = this._settings
36
36
  const job = this._job
37
- const scheduleId = this._scheduleId
37
+ const scheduleId = this._schedule.id
38
38
 
39
39
  const { uuid: srUuid, $xapi: xapi } = sr
40
40
 
@@ -90,7 +90,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
90
90
  async _prepare() {
91
91
  const adapter = this._adapter
92
92
  const settings = this._settings
93
- const scheduleId = this._scheduleId
93
+ const scheduleId = this._schedule.id
94
94
  const vmUuid = this._vmUuid
95
95
 
96
96
  const oldEntries = getOldEntries(
@@ -180,7 +180,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
180
180
  async _transfer($defer, { isVhdDifferencing, timestamp, deltaExport, vm, vmSnapshot }) {
181
181
  const adapter = this._adapter
182
182
  const job = this._job
183
- const scheduleId = this._scheduleId
183
+ const scheduleId = this._schedule.id
184
184
  const settings = this._settings
185
185
  const jobId = job.id
186
186
  const handler = adapter.handler
@@ -1,3 +1,5 @@
1
+ import humanFormat from 'human-format'
2
+
1
3
  import { asyncMapSettled } from '@xen-orchestra/async-map'
2
4
  import ignoreErrors from 'promise-toolbox/ignoreErrors'
3
5
 
@@ -7,7 +9,7 @@ import { Task } from '../../Task.mjs'
7
9
 
8
10
  import { AbstractIncrementalWriter } from './_AbstractIncrementalWriter.mjs'
9
11
  import { MixinXapiWriter } from './_MixinXapiWriter.mjs'
10
- import { listReplicatedVms } from './_listReplicatedVms.mjs'
12
+ import { compareReplicatedVmDatetime, listReplicatedVms } from './_listReplicatedVms.mjs'
11
13
  import {
12
14
  COPY_OF,
13
15
  setVmOtherConfig,
@@ -18,34 +20,102 @@ import {
18
20
  DATETIME,
19
21
  VM_UUID,
20
22
  } from '../../_otherConfig.mjs'
21
- import assert from 'node:assert'
22
23
  import { formatFilenameDate } from '../../_filenameDate.mjs'
24
+ import { XapiDiskSource } from '@xen-orchestra/xapi'
25
+ import { asyncEach } from '@vates/async-each'
26
+ import { createLogger } from '@xen-orchestra/log'
27
+
28
+ const { debug } = createLogger('xo:backups:IncrementalXapiWriter')
23
29
 
24
30
  export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
31
+ // Map of source VDI UUID (COPY_OF) → validated active VDI on the target SR.
32
+ // Built by checkBaseVdis, consumed by #decorateVmMetadata to set baseVdi.
33
+ #baseVdisBySourceUuid = new Map()
34
+
25
35
  async checkBaseVdis(baseUuidToSrcVdi) {
26
36
  const sr = this._sr
37
+ this.#baseVdisBySourceUuid = new Map()
38
+
27
39
  if (baseUuidToSrcVdi.size === 0) {
28
40
  // searching for the vdis is expensive
29
41
  // don't do it if there is nothing to find
30
42
  return
31
43
  }
32
44
 
33
- // @todo use an index if possible
34
- // @todo : this seems similar to decorateVmMetadata
35
- const replicatedVdis = sr.$VDIs
36
- .filter(vdi => {
37
- // REPLICATED_TO_SR_UUID is not used here since we are already filtering from sr.$VDIs
38
- return (
39
- vdi?.managed &&
40
- !vdi?.is_a_snapshot /* only look for real vdi */ &&
41
- baseUuidToSrcVdi.has(vdi?.other_config[COPY_OF])
42
- )
43
- })
44
- .map(({ other_config }) => other_config?.[COPY_OF])
45
- .filter(_ => !!_)
45
+ // look for the same snapshot
46
+ // ensure there are no data between the snapshot and the active disk
47
+
48
+ const snapshotCandidates = sr.$VDIs.filter(vdi => {
49
+ return (
50
+ vdi?.managed &&
51
+ vdi?.is_a_snapshot &&
52
+ vdi.other_config[JOB_ID] === this._job.id &&
53
+ vdi.other_config[VM_UUID] === this._vmUuid &&
54
+ baseUuidToSrcVdi.has(vdi?.other_config[COPY_OF])
55
+ )
56
+ })
57
+ debug('checkBaseVdis, got snapshot candidates,', snapshotCandidates.length)
58
+
59
+ // ensure no data have been written since this snapshot
60
+ // but there may be have some other snapshot for another job
61
+ let targetVmRef
62
+ let canChainToTargetVm = true
63
+ await asyncEach(
64
+ snapshotCandidates,
65
+ async snapshot => {
66
+ let diffDisk
67
+ try {
68
+ const activeVdi = sr.$xapi.getObject(snapshot.$snapshot_of)
69
+ const userVbds = activeVdi.$VBDs?.filter(vbd => vbd.$VM && !vbd.$VM.is_control_domain) ?? []
70
+ if (userVbds.length !== 1) {
71
+ debug('checkBaseVdis, share vbd ', { ref: snapshot.$ref, userVbds })
72
+ // shared vdi ignore
73
+ return
74
+ }
75
+ const vm = userVbds[0].$VM
76
+ if (!('start' in vm.blocked_operations)) {
77
+ debug('checkBaseVdis, vm not blocked', { vmRef: vm.$ref })
78
+ // vm start unlocked
79
+ // not really an issue since we have check the delta
80
+ // but it indicates the users played with the blocked operations
81
+ return
82
+ }
83
+ diffDisk = new XapiDiskSource({ xapi: sr.$xapi, vdiRef: activeVdi.$ref, baseRef: snapshot.$ref })
84
+ await diffDisk.init()
85
+ if (diffDisk.getBlockIndexes().length === 0) {
86
+ const sourceUuid = snapshot.other_config?.[COPY_OF]
87
+ if (sourceUuid) {
88
+ this.#baseVdisBySourceUuid.set(sourceUuid, activeVdi)
89
+ }
90
+ // Track the target VM (the replicated VM to update on the next transfer).
91
+ targetVmRef = vm.$ref
92
+ } else {
93
+ // not empty, we will create a new VM
94
+ canChainToTargetVm = false
95
+ debug('checkBaseVdis, data between snapshot and active disk', {
96
+ vdiRef: snapshot.$ref,
97
+ nbBlocks: diffDisk.getBlockIndexes().length,
98
+ })
99
+ }
100
+ } catch (error) {
101
+ debug('checkBaseVdis, skipping snapshot', { ref: snapshot.$ref, error })
102
+ return
103
+ } finally {
104
+ await diffDisk?.close().catch(error => debug('checkBaseVdis, error closing', error))
105
+ }
106
+ },
107
+ {
108
+ concurrency: 4,
109
+ }
110
+ )
111
+
112
+ if (canChainToTargetVm && targetVmRef !== undefined) {
113
+ debug('checkBaseVdis,got a valid vm target', targetVmRef)
114
+ this._targetVmRef = targetVmRef
115
+ }
46
116
 
47
117
  for (const uuid of baseUuidToSrcVdi.keys()) {
48
- if (!replicatedVdis.includes(uuid)) {
118
+ if (!this.#baseVdisBySourceUuid.has(uuid)) {
49
119
  baseUuidToSrcVdi.delete(uuid)
50
120
  }
51
121
  }
@@ -76,12 +146,21 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
76
146
  const settings = this._settings
77
147
  const { uuid: srUuid, $xapi: xapi } = this._sr
78
148
  const vmUuid = this._vmUuid
79
- const scheduleId = this._scheduleId
149
+ const scheduleId = this._schedule.id
80
150
 
81
151
  // delete previous interrupted copies
82
152
  ignoreErrors.call(asyncMapSettled(listReplicatedVms(xapi, scheduleId, undefined, vmUuid), vm => vm.$destroy))
83
153
 
84
- this._oldEntries = getOldEntries(settings.copyRetention - 1, listReplicatedVms(xapi, scheduleId, srUuid, vmUuid))
154
+ const allEntries = listReplicatedVms(xapi, scheduleId, srUuid, vmUuid)
155
+
156
+ // In the snapshot-based flow a non-snapshot VM (the live target) coexists with its
157
+ // snapshots (one per transfer). That VM must not be subject to retention — only its
158
+ // snapshots are. Build the set of VM refs that already have snapshots in the list so
159
+ // we can exclude them, while keeping old-style non-snapshot VMs (no snapshots).
160
+ const vmRefsWithSnapshots = new Set(allEntries.filter(e => e.is_a_snapshot).map(e => e.snapshot_of))
161
+ const retentionEntries = allEntries.filter(e => e.is_a_snapshot || !vmRefsWithSnapshots.has(e.$ref))
162
+ retentionEntries.sort(compareReplicatedVmDatetime)
163
+ this._oldEntries = getOldEntries(settings.copyRetention - 1, retentionEntries)
85
164
 
86
165
  if (settings.deleteFirst && settings.skipDeleteOldEntries) {
87
166
  // we want to keep the baseVM when copying a delta
@@ -110,9 +189,8 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
110
189
  const sr = this._sr
111
190
  const vm = backup.vm
112
191
  const job = this._job
113
- const scheduleId = this._scheduleId
192
+ const scheduleId = this._schedule.id
114
193
 
115
- vm.name_label = `${vm.name_label} - ${job.name} - (${formatFilenameDate(timestamp)})`
116
194
  // update other_config data as soon as possible to ensure the next job
117
195
  // will be able to detect any partial transfer and lean them
118
196
  vm.other_config[COPY_OF] = vm.uuid
@@ -130,18 +208,6 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
130
208
  if (!_warmMigration) {
131
209
  vm.tags.push('Continuous Replication')
132
210
  }
133
- // extracting the uuid of each delta vdi on the source
134
- // get all in one pass, since there is a lot of objects
135
- const sourceVdiUuids = Object.values(backup.vdis)
136
- .map(({ other_config }) => other_config[BASE_DELTA_VDI])
137
- // full vdi don't have a base
138
- .filter(_ => !!_)
139
- // @todo use index ?
140
-
141
- const replicatedVdis = sr.$VDIs.filter(vdi => {
142
- // REPLICATED_TO_SR_UUID is not used here since we are already filtering from sr.$VDIs
143
- return vdi?.managed && !vdi?.is_a_snapshot && sourceVdiUuids.includes(vdi?.other_config[COPY_OF])
144
- })
145
211
 
146
212
  Object.values(backup.vdis).forEach(vdi => {
147
213
  vdi.other_config[COPY_OF] = vdi.uuid
@@ -150,18 +216,12 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
150
216
  vdi.other_config[REPLICATED_TO_SR_UUID] = sr.uuid
151
217
  vdi.other_config[VM_UUID] = vm.uuid
152
218
 
153
- if (sourceVdiUuids.length > 0) {
154
- const baseReplicatedTo = replicatedVdis.filter(
155
- replicatedVdi => replicatedVdi.other_config[COPY_OF] === vdi.other_config[BASE_DELTA_VDI]
156
- )
157
- assert.ok(
158
- baseReplicatedTo.length <= 1,
159
- `Target of a replication must be unique, got ${baseReplicatedTo.length} candidates`
160
- )
161
- // baseReplicatedTo can be undefined if a new disk is added and other are already replicated
162
- vdi.baseVdi = baseReplicatedTo[0]
219
+ const baseDeltaVdiUuid = vdi.other_config[BASE_DELTA_VDI]
220
+ if (baseDeltaVdiUuid !== undefined) {
221
+ // reuse the validated mapping built by checkBaseVdis
222
+ vdi.baseVdi = this.#baseVdisBySourceUuid.get(baseDeltaVdiUuid)
163
223
  } else {
164
- // first replication of this disk
224
+ // first replication of this disk (full, no base)
165
225
  vdi.baseVdi = undefined
166
226
  }
167
227
  // ensure the VDI are created on the target SR
@@ -175,18 +235,43 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
175
235
  const { _warmMigration } = this._settings
176
236
  const sr = this._sr
177
237
  const job = this._job
178
- const scheduleId = this._scheduleId
238
+ const schedule = this._schedule
239
+ const scheduleId = schedule.id
179
240
  const { uuid: srUuid, $xapi: xapi } = sr
180
241
 
181
242
  let targetVmRef
182
243
  await Task.run({ name: 'transfer' }, async () => {
183
- targetVmRef = await importIncrementalVm(this.#decorateVmMetadata(deltaExport, timestamp), sr)
184
- // size is mandatory to ensure the task have the right data
244
+ targetVmRef = await importIncrementalVm(this.#decorateVmMetadata(deltaExport, timestamp), sr, {
245
+ targetRef: this._targetVmRef,
246
+ })
247
+ // this also ensure the data are up to date on the snapshot
248
+ await setVmOtherConfig(xapi, targetVmRef, {
249
+ timestamp, // updated at the end to mark the transfer as complete
250
+ jobId: job.id,
251
+ scheduleId,
252
+ vmUuid: vm.uuid,
253
+ srUuid,
254
+ })
255
+ const size = Object.values(deltaExport.disks).reduce(
256
+ (sum, disk) => sum + disk.getNbGeneratedBlock() * disk.getBlockSize(),
257
+ 0
258
+ )
259
+ await xapi.setField(
260
+ 'VM',
261
+ targetVmRef,
262
+ 'name_description',
263
+ deltaExport.vm.name_description +
264
+ ` -- last replication: ${formatFilenameDate(timestamp)} ${humanFormat.bytes(size)} read`
265
+ )
266
+ // take a snapshot to ensure these data are not modified until next snapshot
267
+ await Task.run({ name: 'target snapshot' }, async () => {
268
+ await xapi.VM_snapshot(targetVmRef, {
269
+ name_label: `${vm.name_label} - ${job.name} / ${schedule.name} ${formatFilenameDate(timestamp)}`,
270
+ })
271
+ })
272
+
185
273
  return {
186
- size: Object.values(deltaExport.disks).reduce(
187
- (sum, disk) => sum + disk.getNbGeneratedBlock() * disk.getBlockSize(),
188
- 0
189
- ),
274
+ size,
190
275
  }
191
276
  })
192
277
  this._targetVmRef = targetVmRef
@@ -197,13 +282,6 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
197
282
  !_warmMigration &&
198
283
  targetVm.ha_restart_priority !== '' &&
199
284
  Promise.all([targetVm.set_ha_restart_priority(''), targetVm.add_tags('HA disabled')]),
200
- setVmOtherConfig(xapi, targetVmRef, {
201
- timestamp, // updated at the end to mark the transfer as complete
202
- jobId: job.id,
203
- scheduleId,
204
- vmUuid: vm.uuid,
205
- srUuid,
206
- }),
207
285
  ])
208
286
  }
209
287
  }
@@ -1,9 +1,9 @@
1
1
  export class AbstractWriter {
2
- constructor({ config, healthCheckSr, job, vmUuid, scheduleId, settings }) {
2
+ constructor({ config, healthCheckSr, job, vmUuid, schedule, settings }) {
3
3
  this._config = config
4
4
  this._healthCheckSr = healthCheckSr
5
5
  this._job = job
6
- this._scheduleId = scheduleId
6
+ this._schedule = schedule
7
7
  this._settings = settings
8
8
  this._vmUuid = vmUuid
9
9
  }
@@ -50,7 +50,7 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
50
50
 
51
51
  async getLongTermRetentionTags(currentEntry) {
52
52
  const settings = this._settings
53
- const scheduleId = this._scheduleId
53
+ const scheduleId = this._schedule.id
54
54
  const vmUuid = this._vmUuid
55
55
  const adapter = this._adapter
56
56
 
@@ -23,12 +23,13 @@ export function listReplicatedVms(xapi, scheduleOrJobId, srUuid, vmUuid) {
23
23
  const oc = object.other_config
24
24
  if (
25
25
  object.$type === 'VM' &&
26
- !object.is_a_snapshot &&
27
26
  !object.is_a_template &&
28
- 'start' in object.blocked_operations &&
29
27
  (oc[JOB_ID] === scheduleOrJobId || oc[SCHEDULE_ID] === scheduleOrJobId) &&
30
28
  oc[REPLICATED_TO_SR_UUID] === srUuid &&
31
- oc[VM_UUID] === vmUuid
29
+ oc[VM_UUID] === vmUuid &&
30
+ // Old-style replication: one VM per transfer (non-snapshot, start blocked)
31
+ // New-style replication: snapshots of the target VM represent each transfer
32
+ (!object.is_a_snapshot ? 'start' in object.blocked_operations : true)
32
33
  ) {
33
34
  vms[object.$id] = object
34
35
  }
@@ -22,6 +22,7 @@ const { warn } = createLogger('remote-disk:merge')
22
22
  * @property {number} mergedDataSize
23
23
  * @property {'mergeBlocks' | 'cleanup'} step
24
24
  * @property {number} diskSize
25
+ * @typedef {(message: string, data?: Record<string, unknown>) => void} Logger
25
26
  */
26
27
 
27
28
  export class MergeRemoteDisk {
@@ -85,11 +86,11 @@ export class MergeRemoteDisk {
85
86
  /**
86
87
  * @param {FileAccessor} handler
87
88
  * @param {Object} params
88
- * @param {Function} params.onProgress
89
- * @param {Logger | Function} params.logInfo
90
- * @param {boolean} params.removeUnused
91
- * @param {number} params.mergeBlockConcurrency
92
- * @param {number} params.writeStateDelay
89
+ * @param {Function} [params.onProgress]
90
+ * @param {Logger | Function} [params.logInfo]
91
+ * @param {boolean} [params.removeUnused]
92
+ * @param {number} [params.mergeBlockConcurrency]
93
+ * @param {number} [params.writeStateDelay]
93
94
  */
94
95
  constructor(
95
96
  handler,
@@ -202,8 +203,8 @@ export class MergeRemoteDisk {
202
203
 
203
204
  parentDisk.setAllocatedBlocks(alreadyMergedBlocks)
204
205
  } else {
205
- this.#state.child = { uuid: childDisk.getUuid() ?? 0 }
206
- this.#state.parent = { uuid: parentDisk.getUuid() ?? 0 }
206
+ this.#state.child = { uuid: childDisk.getUuid() ?? undefined }
207
+ this.#state.parent = { uuid: parentDisk.getUuid() ?? undefined }
207
208
 
208
209
  // Finds first allocated block for the 2 following loops
209
210
  while (this.#state.currentBlock < getMaxBlockCount && !childDisk.hasBlock(this.#state.currentBlock)) {
@@ -95,10 +95,14 @@ export class RemoteVhdDisk extends RemoteDisk {
95
95
 
96
96
  /**
97
97
  * Closes the VHD.
98
+ * We replace the dispose function call so the disk can only be closed once.
99
+ *
98
100
  * @returns {Promise<void>}
99
101
  */
100
102
  async close() {
101
- await this.#dispose()
103
+ const dispose = this.#dispose
104
+ this.#dispose = () => Promise.resolve()
105
+ await dispose()
102
106
  }
103
107
 
104
108
  /**
@@ -0,0 +1,49 @@
1
+ import { RemoteVhdDisk } from './RemoteVhdDisk.mjs'
2
+
3
+ export { RemoteDisk } from './RemoteDisk.mjs'
4
+ export { openDiskChain } from './openDiskChain.mjs'
5
+
6
+ /**
7
+ * @typedef {import('../../disk-transform/src/FileAccessor.mjs').FileAccessor} FileAccessor
8
+ * @typedef {import('./RemoteDisk.mjs').RemoteDisk} RemoteDisk
9
+ */
10
+
11
+ /**
12
+ * @param {Object} params
13
+ * @param {FileAccessor} params.handler
14
+ * @param {string} params.path
15
+ * @returns {Promise<RemoteDisk>}
16
+ */
17
+ export async function openDisk({ handler, path }) {
18
+ const disk = new RemoteVhdDisk({ handler, path })
19
+ await disk.init()
20
+ return disk
21
+ }
22
+ /**
23
+ *
24
+ * @param {Object} params
25
+ * @param {FileAccessor} params.handler
26
+ * @param {string} params.path
27
+ * @returns {Promise<Disposable<RemoteDisk>>}
28
+ */
29
+ export async function openDisposableDisk({ handler, path }) {
30
+ const disk = new RemoteVhdDisk({ handler, path })
31
+ await disk.init()
32
+ return {
33
+ value: disk,
34
+ dispose: () => disk.close(),
35
+ }
36
+ }
37
+
38
+ const DISK_EXTENSIONS = ['.vhd']
39
+
40
+ /**
41
+ * Returns true if the path points to a supported disk format.
42
+ *
43
+ * @param {FileAccessor} _handler - Remote file handler (reserved for future use)
44
+ * @param {string} path - Path to check
45
+ * @returns {boolean}
46
+ */
47
+ export function isDisk(_handler, path) {
48
+ return DISK_EXTENSIONS.some(ext => path.endsWith(ext))
49
+ }
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "type": "git",
9
9
  "url": "https://github.com/vatesfr/xen-orchestra.git"
10
10
  },
11
- "version": "0.69.4",
11
+ "version": "0.71.0",
12
12
  "engines": {
13
13
  "node": ">=14.18"
14
14
  },
@@ -19,20 +19,20 @@
19
19
  "dependencies": {
20
20
  "@iarna/toml": "^2.2.5",
21
21
  "@kldzj/stream-throttle": "^1.1.1",
22
- "@vates/async-each": "^1.0.1",
22
+ "@vates/async-each": "^1.0.2",
23
23
  "@vates/cached-dns.lookup": "^1.0.0",
24
24
  "@vates/compose": "^2.1.0",
25
25
  "@vates/decorate-with": "^2.1.0",
26
26
  "@vates/disposable": "^0.1.6",
27
27
  "@vates/fuse-vhd": "^2.1.2",
28
- "@vates/generator-toolbox": "^1.1.0",
29
- "@vates/nbd-client": "^3.2.3",
28
+ "@vates/generator-toolbox": "^1.1.1",
29
+ "@vates/nbd-client": "^3.3.0",
30
30
  "@vates/parse-duration": "^0.1.1",
31
31
  "@xen-orchestra/async-map": "^0.1.2",
32
- "@xen-orchestra/disk-transform": "^1.2.1",
33
- "@xen-orchestra/fs": "^4.6.7",
32
+ "@xen-orchestra/disk-transform": "^1.2.2",
33
+ "@xen-orchestra/fs": "^4.7.0",
34
34
  "@xen-orchestra/log": "^0.7.1",
35
- "@xen-orchestra/qcow2": "^1.1.2",
35
+ "@xen-orchestra/qcow2": "^1.2.0",
36
36
  "@xen-orchestra/template": "^0.1.0",
37
37
  "app-conf": "^3.0.0",
38
38
  "compare-versions": "^6.0.0",
@@ -51,7 +51,7 @@
51
51
  "tar": "^7.5.3",
52
52
  "uuid": "^9.0.0",
53
53
  "value-matcher": "^0.2.0",
54
- "vhd-lib": "^4.14.7",
54
+ "vhd-lib": "^4.15.0",
55
55
  "xen-api": "^4.7.6",
56
56
  "yazl": "^2.5.1"
57
57
  },
@@ -62,11 +62,15 @@
62
62
  "tmp": "^0.2.1"
63
63
  },
64
64
  "peerDependencies": {
65
- "@xen-orchestra/xapi": "^8.6.6"
65
+ "@xen-orchestra/xapi": "^8.7.0"
66
66
  },
67
67
  "license": "AGPL-3.0-or-later",
68
68
  "author": {
69
69
  "name": "Vates SAS",
70
70
  "url": "https://vates.fr"
71
+ },
72
+ "exports": {
73
+ "./disks": "./disks/index.mjs",
74
+ "./*": "./*"
71
75
  }
72
76
  }