@xen-orchestra/backups 0.69.4 → 0.70.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
  }
@@ -131,3 +136,24 @@ export async function markExportSuccessfull(xapi, vmRef) {
131
136
  xapi.setFieldEntry(type, ref, 'other_config', EXPORTED_SUCCESSFULLY, 'true')
132
137
  )
133
138
  }
139
+
140
+ /**
141
+ * Set CONTENT_KEY on each VDI of a VM snapshot to its own UUID.
142
+ *
143
+ * This marks each snapshot VDI with a unique content identifier that can be
144
+ * propagated to replicated copies for direction-agnostic base VDI discovery
145
+ * (e.g. for failback / reverse replication).
146
+ *
147
+ * @param {Xapi} xapi
148
+ * @param {String} snapshotRef
149
+ * @returns {Promise}
150
+ */
151
+ export async function setVmSnapshotContentKeys(xapi, snapshotRef) {
152
+ return applyToVmAndVdis(xapi, snapshotRef, async (type, ref) => {
153
+ if (type !== 'VDI') {
154
+ return
155
+ }
156
+ const uuid = await xapi.getField(type, ref, 'uuid')
157
+ return xapi.setFieldEntry(type, ref, 'other_config', CONTENT_KEY, uuid)
158
+ })
159
+ }
@@ -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
 
@@ -205,6 +214,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
205
214
  scheduleId: this.scheduleId,
206
215
  vmUuid: vm.uuid,
207
216
  })
217
+ await setVmSnapshotContentKeys(xapi, snapshotRef)
208
218
  const snapshot = await xapi.getRecord('VM', snapshotRef)
209
219
  await snapshot.set_name_label(this._getSnapshotNameLabel(vm))
210
220
  // reload data to ensure it is up to date with the new name label
@@ -233,11 +243,16 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
233
243
  const xapi = this._xapi
234
244
 
235
245
  const vdiCandidates = {}
236
-
246
+ const vdiUuids = this._vm.$VBDs.map(({ VDI }) => VDI)
237
247
  Object.values(xapi.objects.indexes.type.VDI)
238
248
  .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
249
+ .filter(({ other_config, snapshot_of }) => {
250
+ return (
251
+ vdiUuids.includes(snapshot_of) &&
252
+ other_config[JOB_ID] === jobId &&
253
+ other_config[VM_UUID] === this._vm.uuid &&
254
+ other_config[COPY_OF] === undefined
255
+ )
241
256
  })
242
257
  .forEach(vdi => {
243
258
  vdiCandidates[vdi.uuid] = vdi
@@ -7,7 +7,7 @@ import { Task } from '../../Task.mjs'
7
7
 
8
8
  import { AbstractIncrementalWriter } from './_AbstractIncrementalWriter.mjs'
9
9
  import { MixinXapiWriter } from './_MixinXapiWriter.mjs'
10
- import { listReplicatedVms } from './_listReplicatedVms.mjs'
10
+ import { compareReplicatedVmDatetime, listReplicatedVms } from './_listReplicatedVms.mjs'
11
11
  import {
12
12
  COPY_OF,
13
13
  setVmOtherConfig,
@@ -30,25 +30,38 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
30
30
  return
31
31
  }
32
32
 
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(_ => !!_)
33
+ // Only match live (non-snapshot) VDIs attached to exactly one
34
+ // non-control-domain VM that is a valid replication target for this job.
35
+ // This must stay consistent with the filter in #decorateVmMetadata.
36
+ const replicatedVdis = sr.$VDIs.filter(vdi => {
37
+ if (!vdi?.managed || vdi?.is_a_snapshot || !baseUuidToSrcVdi.has(vdi?.other_config[COPY_OF])) {
38
+ return false
39
+ }
40
+ const userVbds = vdi.$VBDs?.filter(vbd => vbd.$VM && !vbd.$VM.is_control_domain) ?? []
41
+ if (userVbds.length !== 1) {
42
+ return false
43
+ }
44
+ const vm = userVbds[0].$VM
45
+ return (
46
+ vm.other_config[JOB_ID] === this._job.id &&
47
+ vm.other_config[VM_UUID] === this._vmUuid &&
48
+ 'start' in vm.blocked_operations
49
+ )
50
+ })
51
+
52
+ const replicatedCopyOfUuids = replicatedVdis.map(({ other_config }) => other_config?.[COPY_OF]).filter(_ => !!_)
46
53
 
47
54
  for (const uuid of baseUuidToSrcVdi.keys()) {
48
- if (!replicatedVdis.includes(uuid)) {
55
+ if (!replicatedCopyOfUuids.includes(uuid)) {
49
56
  baseUuidToSrcVdi.delete(uuid)
50
57
  }
51
58
  }
59
+
60
+ // Track the target VM (the replicated VM to update on the next transfer).
61
+ if (replicatedVdis.length > 0) {
62
+ const vbd = replicatedVdis[0].$VBDs.find(vbd => vbd.$VM && !vbd.$VM.is_control_domain)
63
+ this._targetVmRef = vbd.$VM.$ref
64
+ }
52
65
  }
53
66
  updateUuidAndChain() {
54
67
  // nothing to do, the chaining is not modified in this case
@@ -81,7 +94,16 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
81
94
  // delete previous interrupted copies
82
95
  ignoreErrors.call(asyncMapSettled(listReplicatedVms(xapi, scheduleId, undefined, vmUuid), vm => vm.$destroy))
83
96
 
84
- this._oldEntries = getOldEntries(settings.copyRetention - 1, listReplicatedVms(xapi, scheduleId, srUuid, vmUuid))
97
+ const allEntries = listReplicatedVms(xapi, scheduleId, srUuid, vmUuid)
98
+
99
+ // In the snapshot-based flow a non-snapshot VM (the live target) coexists with its
100
+ // snapshots (one per transfer). That VM must not be subject to retention — only its
101
+ // snapshots are. Build the set of VM refs that already have snapshots in the list so
102
+ // we can exclude them, while keeping old-style non-snapshot VMs (no snapshots).
103
+ const vmRefsWithSnapshots = new Set(allEntries.filter(e => e.is_a_snapshot).map(e => e.snapshot_of))
104
+ const retentionEntries = allEntries.filter(e => e.is_a_snapshot || !vmRefsWithSnapshots.has(e.$ref))
105
+ retentionEntries.sort(compareReplicatedVmDatetime)
106
+ this._oldEntries = getOldEntries(settings.copyRetention - 1, retentionEntries)
85
107
 
86
108
  if (settings.deleteFirst && settings.skipDeleteOldEntries) {
87
109
  // we want to keep the baseVM when copying a delta
@@ -112,7 +134,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
112
134
  const job = this._job
113
135
  const scheduleId = this._scheduleId
114
136
 
115
- vm.name_label = `${vm.name_label} - ${job.name} - (${formatFilenameDate(timestamp)})`
137
+ vm.name_label = `${vm.name_label} - ${job.name}`
116
138
  // update other_config data as soon as possible to ensure the next job
117
139
  // will be able to detect any partial transfer and lean them
118
140
  vm.other_config[COPY_OF] = vm.uuid
@@ -180,7 +202,24 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
180
202
 
181
203
  let targetVmRef
182
204
  await Task.run({ name: 'transfer' }, async () => {
183
- targetVmRef = await importIncrementalVm(this.#decorateVmMetadata(deltaExport, timestamp), sr)
205
+ targetVmRef = await importIncrementalVm(this.#decorateVmMetadata(deltaExport, timestamp), sr, {
206
+ targetRef: this._targetVmRef,
207
+ })
208
+ // this also ensure the data are up to date on the snapshot
209
+ await setVmOtherConfig(xapi, targetVmRef, {
210
+ timestamp, // updated at the end to mark the transfer as complete
211
+ jobId: job.id,
212
+ scheduleId,
213
+ vmUuid: vm.uuid,
214
+ srUuid,
215
+ })
216
+
217
+ // take a snapshot to ensure these data are not modified until next snapshot
218
+ await Task.run({ name: 'target snapshot' }, () =>
219
+ xapi.VM_snapshot(targetVmRef, {
220
+ name_label: `${vm.name_label} - ${job.name} - (${formatFilenameDate(timestamp)})`,
221
+ })
222
+ )
184
223
  // size is mandatory to ensure the task have the right data
185
224
  return {
186
225
  size: Object.values(deltaExport.disks).reduce(
@@ -197,13 +236,6 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
197
236
  !_warmMigration &&
198
237
  targetVm.ha_restart_priority !== '' &&
199
238
  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
239
  ])
208
240
  }
209
241
  }
@@ -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)) {
@@ -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.70.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
  },
@@ -68,5 +68,9 @@
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
  }