@xen-orchestra/backups 0.72.1 → 0.73.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.
@@ -10,7 +10,7 @@ import { dirname, join } from 'node:path'
10
10
  import pickBy from 'lodash/pickBy.js'
11
11
  import { defer } from 'golike-defer'
12
12
  import { NegativeDisk } from '@xen-orchestra/disk-transform'
13
- import { openDiskChain } from './disks/openDiskChain.mjs'
13
+ import { openDiskChain } from '@xen-orchestra/backup-archive/disks'
14
14
  import { resetVmOtherConfig } from './_otherConfig.mjs'
15
15
 
16
16
  const { debug, info, warn } = createLogger('xo:backups:importVmBackup')
package/RemoteAdapter.mjs CHANGED
@@ -22,7 +22,7 @@ import * as tar from 'tar'
22
22
  import zlib from 'zlib'
23
23
 
24
24
  import { BACKUP_DIR } from './_getVmBackupDir.mjs'
25
- import { cleanVm } from './_cleanVm.mjs'
25
+ import { VmBackupDirectory } from '@xen-orchestra/backup-archive'
26
26
  import { formatFilenameDate } from './_filenameDate.mjs'
27
27
  import { getTmpDir } from './_getTmpDir.mjs'
28
28
  import { isMetadataFile } from './_backupType.mjs'
@@ -31,8 +31,7 @@ import { listPartitions, LVM_PARTITION_TYPE_MBR, LVM_PARTITION_TYPE_GPT } from '
31
31
  import { lvs, pvs } from './_lvm.mjs'
32
32
  import { watchStreamSize } from './_watchStreamSize.mjs'
33
33
 
34
- import { RemoteVhdDisk } from './disks/RemoteVhdDisk.mjs'
35
- import { openDiskChain } from './disks/openDiskChain.mjs'
34
+ import { RemoteVhdDisk, openDiskChain } from '@xen-orchestra/backup-archive/disks'
36
35
  import { toVhdStream, writeToVhdDirectory } from 'vhd-lib/disk-consumer/index.mjs'
37
36
  import { ReadAhead } from '@xen-orchestra/disk-transform'
38
37
 
@@ -879,11 +878,14 @@ export class RemoteAdapter {
879
878
  }
880
879
 
881
880
  Object.assign(RemoteAdapter.prototype, {
882
- cleanVm(vmDir, { lock = true } = {}) {
881
+ cleanVm(vmBackupPath, opts = {}) {
882
+ const { lock = true, ...cleanOpts } = opts
883
883
  if (lock) {
884
- return Disposable.use(this._handler.lock(vmDir), () => cleanVm.apply(this, arguments))
884
+ return Disposable.use(this._handler.lock(vmBackupPath), () => {
885
+ return VmBackupDirectory.cleanVm(this._handler, vmBackupPath, cleanOpts)
886
+ })
885
887
  } else {
886
- return cleanVm.apply(this, arguments)
888
+ return VmBackupDirectory.cleanVm(this._handler, vmBackupPath, cleanOpts)
887
889
  }
888
890
  },
889
891
  isValidXva,
package/_otherConfig.mjs CHANGED
@@ -85,6 +85,7 @@ export async function getVmDeltaChainLength(xapi, vmRef) {
85
85
  export function resetVmOtherConfig(xapi, vmRef) {
86
86
  return applyToVmAndVdis(xapi, vmRef, (type, ref) => {
87
87
  return xapi.setFieldEntries(type, ref, 'other_config', {
88
+ [CONTENT_KEY]: null,
88
89
  [COPY_OF]: null,
89
90
  [DATETIME]: null,
90
91
  [DELTA_CHAIN_LENGTH]: null,
@@ -92,6 +93,7 @@ export function resetVmOtherConfig(xapi, vmRef) {
92
93
  [JOB_ID]: null,
93
94
  [SCHEDULE_ID]: null,
94
95
  [VM_UUID]: null,
96
+
95
97
  // REPLICATED_TO_SR_UUID is not reset since we can replicate a replication
96
98
  })
97
99
  })
@@ -8,6 +8,7 @@ import { exportIncrementalVm } from '../../_incrementalVm.mjs'
8
8
  import { IncrementalRemoteWriter } from '../_writers/IncrementalRemoteWriter.mjs'
9
9
  import { IncrementalXapiWriter } from '../_writers/IncrementalXapiWriter.mjs'
10
10
  import {
11
+ CONTENT_KEY,
11
12
  DATETIME,
12
13
  DELTA_CHAIN_LENGTH,
13
14
  EXPORTED_SUCCESSFULLY,
@@ -101,63 +102,136 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
101
102
  await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
102
103
  }
103
104
 
105
+ /**
106
+ * Groups snapshot VDIs by their DATETIME other_config value and sorts them most-recent-first.
107
+ *
108
+ * @param {import('@vates/types').XenApiVdi[]} snapshotVdis
109
+ * @returns {{ datetime: string, vdis: import('@vates/types').XenApiVdi[] }[]}
110
+ */
111
+ _groupSnapshotVdisByDatetime(snapshotVdis) {
112
+ const byDatetime = new Map()
113
+ for (const vdi of snapshotVdis) {
114
+ const datetime = vdi.other_config[DATETIME]
115
+ if (!byDatetime.has(datetime)) {
116
+ byDatetime.set(datetime, [])
117
+ }
118
+ byDatetime.get(datetime).push(vdi)
119
+ }
120
+ // sort them by decreasing datetime
121
+ return [...byDatetime.entries()]
122
+ .sort(([a], [b]) => b.localeCompare(a))
123
+ .map(([datetime, vdis]) => ({ datetime, vdis }))
124
+ }
125
+
126
+ /**
127
+ * For each snapshot VDI in `snapshotVdis`, maps it to its live source VDI,
128
+ * attaches its CONTENT_KEY when present, calls checkBaseVdis on all writers,
129
+ * and returns both the full mapping and the subset confirmed present by writers.
130
+ *
131
+ * @param {import('@vates/types').XenApiVdi[]} snapshotVdis - Snapshot VDI records to use as base candidates
132
+ * @param {Record<string, object>} srcVdisByRef - Live VM VDI records keyed by $ref
133
+ * @param {{ useContentKey?: boolean }} [options]
134
+ * @returns {Promise<{ presentBaseVdis: Map<string, string>, baseUuidToSrcVdiUuid: Map<string, string>}>}
135
+ */
136
+ async _checkBaseVdis(snapshotVdis, srcVdisByRef, { useContentKey = false } = {}) {
137
+ const baseUuidToSrcVdiUuid = new Map()
138
+ const baseUuidToContentKey = new Map()
139
+ for (const snapshotVdi of snapshotVdis) {
140
+ const baseUuid = snapshotVdi.uuid
141
+ const srcVdi = srcVdisByRef[snapshotVdi.snapshot_of]
142
+ if (srcVdi !== undefined) {
143
+ baseUuidToSrcVdiUuid.set(baseUuid, srcVdi.uuid)
144
+ if (useContentKey) {
145
+ const contentKey = snapshotVdi.other_config[CONTENT_KEY]
146
+ if (contentKey !== undefined) {
147
+ baseUuidToContentKey.set(baseUuid, contentKey)
148
+ }
149
+ }
150
+ } else {
151
+ debug('ignore snapshot VDI because no longer present on VM', { vdi: baseUuid })
152
+ }
153
+ }
154
+
155
+ const presentBaseVdis = new Map(baseUuidToSrcVdiUuid)
156
+ await this._callWriters(
157
+ writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis, baseUuidToContentKey),
158
+ 'writer.checkBaseVdis()',
159
+ false
160
+ )
161
+ return { presentBaseVdis, baseUuidToSrcVdiUuid }
162
+ }
163
+
104
164
  async _selectBaseVm() {
105
165
  const xapi = this._xapi
106
166
 
107
- // filter _jobSnapshotVdis to have only the last successfully exported vdi
108
- // compute delta chain length
109
- // fill baseUuidToSrcVdi with this data
110
-
111
167
  const exportedVdis = this._jobSnapshotVdis.filter(
112
168
  _ => EXPORTED_SUCCESSFULLY in _.other_config && DATETIME in _.other_config
113
169
  )
114
- let lastSuccessfullBackup
115
- let lastExportedVdis = []
116
- for (const exportedVdi of exportedVdis) {
117
- if (lastSuccessfullBackup === undefined || lastSuccessfullBackup < exportedVdi.other_config[DATETIME]) {
118
- lastExportedVdis = []
119
- lastSuccessfullBackup = exportedVdi.other_config[DATETIME]
120
- }
121
- if (lastSuccessfullBackup === exportedVdi.other_config[DATETIME]) {
122
- lastExportedVdis.push(exportedVdi)
123
- }
124
- }
170
+ const grouped = this._groupSnapshotVdisByDatetime(exportedVdis)
125
171
 
126
172
  this._baseVdis = {}
127
- if (lastExportedVdis.length === 0) {
128
- debug('no base VDIS found', {
173
+ if (grouped.length === 0) {
174
+ debug('no base VDIs found', {
129
175
  jobLength: this._jobSnapshotVdis.length,
130
- lastSuccessfullBackup,
131
176
  exportedLength: exportedVdis.length,
132
177
  })
133
- return
134
178
  }
135
- const deltaChainLength = Math.max(
136
- ...lastExportedVdis.map(({ other_config }) => Number(other_config[DELTA_CHAIN_LENGTH] ?? 0))
137
- )
138
-
139
179
  const srcVdis = keyBy(await xapi.getRecords('VDI', await this._vm.$getDisks()), '$ref')
180
+ let lastExportedVdis = []
181
+ let presentBaseVdis = new Map()
182
+ let baseUuidToSrcVdiUuid = new Map()
183
+ let deltaChainLength
140
184
 
141
- const baseUuidToSrcVdiUuid = new Map()
142
- for (const lastExportedVdi of lastExportedVdis) {
143
- const baseUuid = lastExportedVdi.uuid
144
- const snapshotOf = lastExportedVdi.snapshot_of
145
- const srcVdi = srcVdis[snapshotOf]
146
- if (srcVdi !== undefined) {
147
- baseUuidToSrcVdiUuid.set(baseUuid, srcVdi.uuid)
148
- } else {
149
- debug('ignore snapshot VDI because no longer present on VM', {
150
- vdi: baseUuid,
151
- })
152
- }
185
+ if (grouped.length > 0) {
186
+ lastExportedVdis = grouped[0].vdis
187
+ // only compute it on the _jobSnapshotVdis , not on the reused snapshot from CONTENT_KEYS
188
+ deltaChainLength = Math.max(
189
+ ...lastExportedVdis.map(({ other_config }) => Number(other_config[DELTA_CHAIN_LENGTH] ?? 0))
190
+ )
191
+ ;({ presentBaseVdis, baseUuidToSrcVdiUuid } = await this._checkBaseVdis(lastExportedVdis, srcVdis))
153
192
  }
154
193
 
155
- const presentBaseVdis = new Map(baseUuidToSrcVdiUuid)
156
- await this._callWriters(
157
- writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis),
158
- 'writer.checkBaseVdis()',
159
- false
160
- )
194
+ // if we don't find any candidates
195
+ // if there is only one writer of type IncrementalXapiWriter
196
+ // allow the reuse of any matching snapshot of this VM even from another job
197
+ if (
198
+ presentBaseVdis.size === 0 &&
199
+ this._writers.size === 1 &&
200
+ [...this._writers][0] instanceof IncrementalXapiWriter
201
+ ) {
202
+ debug('fallback to content key')
203
+ const candidates = {}
204
+ for (const srcVdi of Object.values(srcVdis)) {
205
+ const snapshots = srcVdi.$snapshots
206
+ debug(`${srcVdi.uuid} got ${snapshots.length} snapshots`)
207
+ for (const snapshot of snapshots) {
208
+ if (DATETIME in snapshot.other_config) {
209
+ candidates[snapshot.uuid] = snapshot
210
+ }
211
+ }
212
+ }
213
+
214
+ debug('got candidates ', Object.keys(candidates).length)
215
+ this._filterValidSnapshotVdis(candidates)
216
+ debug('got filtered ', Object.keys(candidates).length)
217
+ for (const group of this._groupSnapshotVdisByDatetime(Object.values(candidates))) {
218
+ debug('check by datetime ', [...group.vdis].length)
219
+ debug(group.vdis)
220
+ const result = await this._checkBaseVdis(group.vdis, srcVdis, { useContentKey: true })
221
+ debug('result ', result)
222
+ if (result.presentBaseVdis.size > 0) {
223
+ lastExportedVdis = group.vdis
224
+
225
+ presentBaseVdis = result.presentBaseVdis
226
+ baseUuidToSrcVdiUuid = result.baseUuidToSrcVdiUuid
227
+
228
+ break
229
+ }
230
+ debug('try next group if any')
231
+ }
232
+ } else {
233
+ debug(' no fallback ')
234
+ }
161
235
 
162
236
  if (presentBaseVdis.size === 0) {
163
237
  debug('no base VM found')
@@ -236,28 +236,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
236
236
  // ensure they are attached to only one vm snapshot
237
237
  // ensure any VM-snapshot harvested by this has all its disk harvested (no mix of vdi snapshot from this job and not)
238
238
 
239
- async _fetchJobSnapshots() {
240
- const jobId = this._jobId
241
- const xapi = this._xapi
242
-
243
- const vdiCandidates = {}
244
- const vdiUuids = this._vm.$VBDs.map(({ VDI }) => VDI)
245
- Object.values(xapi.objects.indexes.type.VDI)
246
- .filter(_ => !!_) // filter nullish
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
- )
254
- })
255
- .forEach(vdi => {
256
- vdiCandidates[vdi.uuid] = vdi
257
- })
258
-
259
- // check that user snapshots are clean
260
-
239
+ _filterValidSnapshotVdis(vdiCandidates) {
261
240
  for (const vdi of Object.values(vdiCandidates)) {
262
241
  // cbt metadata are always considered linked to a backup job
263
242
  // if they have the right other_config
@@ -279,7 +258,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
279
258
  const userVms = vbds.map(({ $VM }) => $VM)
280
259
  if (vbds.length > 1) {
281
260
  warn(
282
- `vdi ${vdi.name_label} (${vdi.uuid}) is linked to multipe vms : ${userVms.map(({ name_label, uuid }) => `${name_label} ${uuid}`).join(', ')}.
261
+ `vdi ${vdi.name_label} (${vdi.uuid}) is linked to multipe vms : ${userVms.map(({ name_label, uuid }) => `${name_label} ${uuid}`).join(', ')}.
283
262
  This disk snapshot will be excluded from the backup cleaning`,
284
263
  { vdi, userVms }
285
264
  )
@@ -294,7 +273,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
294
273
  // => we exclude these from the backup processing
295
274
  if (vm.$snapshot_of === undefined) {
296
275
  warn(
297
- `vdi ${vdi.name_label} (${vdi.uuid}) is a snapshot linked to a non snapshot vm ${vm.name_label} ${vm.uuid}.
276
+ `vdi ${vdi.name_label} (${vdi.uuid}) is a snapshot linked to a non snapshot vm ${vm.name_label} ${vm.uuid}.
298
277
  This disk snapshot will be excluded from the backup cleaning`,
299
278
  { vdi, vm }
300
279
  )
@@ -311,7 +290,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
311
290
  vm.other_config[VM_UUID] !== vdi.other_config[VM_UUID]
312
291
  ) {
313
292
  warn(
314
- `vdi ${vdi.name_label} (${vdi.uuid}) is a snapshot linked to a snapshot vm ${vm.name_label} ${vm.uuid} out of this backup job scope.
293
+ `vdi ${vdi.name_label} (${vdi.uuid}) is a snapshot linked to a snapshot vm ${vm.name_label} ${vm.uuid} out of this backup job scope.
315
294
  This disk snapshot will be excluded from the backup cleaning`,
316
295
  { vdi, vm }
317
296
  )
@@ -328,7 +307,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
328
307
  .forEach(({ $VDI: outOfSnapshotsVdi, ...other }) => {
329
308
  warn(
330
309
  `vdi ${vdi.name_label} ${vdi.uuid} is recognized as a snapshot of the backup job,
331
- linked to vm ${vm.name_label} ${vm.uuid} but vdi ${outOfSnapshotsVdi.name_label} ${outOfSnapshotsVdi.uuid}
310
+ linked to vm ${vm.name_label} ${vm.uuid} but vdi ${outOfSnapshotsVdi.name_label} ${outOfSnapshotsVdi.uuid}
332
311
  is not linked to the job. This disk snapshot will be excluded from the backup cleaning`,
333
312
  { vdi, vm, vbds, outOfSnapshotsVdi }
334
313
  )
@@ -336,19 +315,44 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
336
315
  delete vdiCandidates[vdi.uuid]
337
316
  })
338
317
  }
318
+ }
319
+
320
+ async _fetchJobSnapshots() {
321
+ const jobId = this._jobId
322
+ const xapi = this._xapi
323
+
324
+ const vdiCandidates = {}
325
+ const vdiUuids = this._vm.$VBDs.map(({ VDI }) => VDI)
326
+ Object.values(xapi.objects.indexes.type.VDI)
327
+ .filter(_ => !!_) // filter nullish
328
+ .filter(({ other_config, snapshot_of }) => {
329
+ return (
330
+ vdiUuids.includes(snapshot_of) &&
331
+ other_config[JOB_ID] === jobId &&
332
+ other_config[VM_UUID] === this._vm.uuid &&
333
+ other_config[COPY_OF] === undefined
334
+ )
335
+ })
336
+ .forEach(vdi => {
337
+ vdiCandidates[vdi.uuid] = vdi
338
+ })
339
+
340
+ this._filterValidSnapshotVdis(vdiCandidates)
339
341
 
340
342
  this._jobSnapshotVdis = Object.values(vdiCandidates)
341
343
 
342
344
  // For VMs with no disks, retention must be tracked directly on VM snapshots
343
345
  // since there are no VDIs to anchor the discovery.
344
346
  if (vdiUuids.length === 0) {
345
- this._disklessJobSnapshotVms = this._vm.$snapshots.filter(Boolean).filter(
346
- ({ other_config, $snapshot_of }) =>
347
- $snapshot_of !== undefined &&
348
- other_config[JOB_ID] === jobId &&
349
- other_config[VM_UUID] === this._vm.uuid &&
350
- other_config[COPY_OF] === undefined
351
- )
347
+ this._disklessJobSnapshotVms = this._vm.$snapshots
348
+ .filter(Boolean)
349
+ .filter(
350
+ ({ other_config, $snapshot_of }) =>
351
+ $snapshot_of !== undefined &&
352
+ other_config[JOB_ID] === jobId &&
353
+ other_config[VM_UUID] === this._vm.uuid &&
354
+ other_config[COPY_OF] === undefined
355
+ )
352
356
  } else {
353
357
  this._disklessJobSnapshotVms = []
354
358
  }
@@ -447,7 +451,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
447
451
  if (disklessVmSnapshots.length > 0) {
448
452
  const snapshotsPerSchedule = groupBy(disklessVmSnapshots, _ => _.other_config[SCHEDULE_ID])
449
453
  await asyncEach(Object.entries(snapshotsPerSchedule), async ([scheduleId, snapshots]) => {
450
- // we only have one snapshot per date time since it's at the VM level
454
+ // we only have one snapshot per date time since it's at the VM level
451
455
  const snapshotPerDatetime = Object.fromEntries(snapshots.map(s => [s.other_config[DATETIME], s.$ref]))
452
456
  const datetimes = Object.keys(snapshotPerDatetime).sort()
453
457
  const settings = {
@@ -19,11 +19,14 @@ import {
19
19
  REPLICATED_TO_SR_UUID,
20
20
  DATETIME,
21
21
  VM_UUID,
22
+ CONTENT_KEY,
23
+ resetVmOtherConfig,
22
24
  } from '../../_otherConfig.mjs'
23
25
  import { formatFilenameDate } from '../../_filenameDate.mjs'
24
26
  import { XapiDiskSource } from '@xen-orchestra/xapi'
25
27
  import { asyncEach } from '@vates/async-each'
26
28
  import { createLogger } from '@xen-orchestra/log'
29
+ import { VM_POWER_STATE } from '@vates/types'
27
30
 
28
31
  const { debug } = createLogger('xo:backups:IncrementalXapiWriter')
29
32
 
@@ -32,7 +35,20 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
32
35
  // Built by checkBaseVdis, consumed by #decorateVmMetadata to set baseVdi.
33
36
  #baseVdisBySourceUuid = new Map()
34
37
 
35
- async checkBaseVdis(baseUuidToSrcVdi) {
38
+ /**
39
+ * Finds VDIs on the target SR that can serve as base for the next delta transfer.
40
+ *
41
+ * For each entry in `baseUuidToSrcVdi`, searches the target SR for a matching snapshot
42
+ * using CONTENT_KEY when available, then falls back to COPY_OF matching for older snapshots.
43
+ * Entries with no matching base found on the target SR are removed from `baseUuidToSrcVdi`.
44
+ *
45
+ * Side-effects: populates `#baseVdisBySourceUuid`; may set `_targetVmRef`.
46
+ *
47
+ * @param {Map<string, string>} baseUuidToSrcVdi - Source snapshot UUID → source active VDI UUID. Mutated in place.
48
+ * @param {Map<string, string>} contentKeys - Source snapshot UUID → CONTENT_KEY value (empty for pre-CONTENT_KEY snapshots).
49
+ * @returns {Promise<void>}
50
+ */
51
+ async checkBaseVdis(baseUuidToSrcVdi, contentKeys) {
36
52
  const sr = this._sr
37
53
  this.#baseVdisBySourceUuid = new Map()
38
54
 
@@ -45,18 +61,45 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
45
61
  // look for the same snapshot
46
62
  // ensure there are no data between the snapshot and the active disk
47
63
 
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)
64
+ const snapshotCandidates = new Map()
65
+
66
+ for (const [baseUuid, srcVdiuid] of baseUuidToSrcVdi) {
67
+ let target
68
+
69
+ const contentKey = contentKeys.get(baseUuid)
70
+
71
+ if (contentKey !== undefined) {
72
+ debug('got one content key, look for a candidate')
73
+ target = sr.$VDIs.find(
74
+ vdi =>
75
+ vdi?.managed &&
76
+ vdi?.is_a_snapshot &&
77
+ vdi.other_config[CONTENT_KEY] === contentKey && // &&
78
+ // ensure we don't replicate on ourself or any of the vdi in the source chain
79
+ vdi.$snapshot_of.uuid !== srcVdiuid
80
+ )
81
+ }
82
+
83
+ // fall back for older snapshots
84
+ if (target === undefined) {
85
+ debug('content key not here or not found , look by jobid ')
86
+ target = sr.$VDIs.find(
87
+ vdi =>
88
+ vdi?.managed &&
89
+ vdi?.is_a_snapshot &&
90
+ vdi.other_config[JOB_ID] === this._job.id &&
91
+ vdi.other_config[VM_UUID] === this._vmUuid &&
92
+ vdi?.other_config[COPY_OF] === baseUuid
93
+ )
94
+ }
95
+ if (target !== undefined) {
96
+ snapshotCandidates.set(baseUuid, target)
97
+ }
98
+ }
58
99
 
59
- if (snapshotCandidates.length > 0) {
100
+ debug('checkBaseVdis, got snapshot candidates,', snapshotCandidates.size)
101
+
102
+ if (snapshotCandidates.size > 0) {
60
103
  // reset before searching for candidates
61
104
  this.#baseVdisBySourceUuid = new Map()
62
105
  this._targetVmRef = undefined
@@ -64,10 +107,12 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
64
107
  for (const [sourceUuid, vdi] of baseVdisBySourceUuid) {
65
108
  this.#baseVdisBySourceUuid.set(sourceUuid, vdi)
66
109
  }
110
+ debug(' this.#baseVdisBySourceUuid ', this.#baseVdisBySourceUuid.size)
67
111
  if (targetVmRef !== undefined) {
68
112
  this._targetVmRef = targetVmRef
69
113
  }
70
114
  } else {
115
+ debug('legacy fallback ( no content key ) ')
71
116
  // Legacy fallback (upgrade from pre-6.3): no target snapshots exist yet,
72
117
  // look for active (non-snapshot) VDIs with matching COPY_OF, like the old code did.
73
118
  debug('checkBaseVdis, no snapshot candidates, falling back to legacy active VDI lookup')
@@ -92,6 +137,9 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
92
137
  * 6.3+ snapshot-based validation: for each snapshot candidate, check whether
93
138
  * the active VDI has diverged from the snapshot. Returns a baseVdisBySourceUuid
94
139
  * map and, when all disks are clean, the targetVmRef to reuse.
140
+ *
141
+ * @param {Map<XenApiVdi['id'], import('@vates/types').XenApiVdi>} snapshotCandidates - Snapshot VDIs on the target SR to validate.
142
+ * @returns {Promise<{ baseVdisBySourceUuid: Map<string, import('@vates/types').XenApiVdi>, targetVmRef: import('@vates/types').XenApiVm['$ref'] | undefined }>}
95
143
  */
96
144
  async #validateSnapshotCandidates(snapshotCandidates) {
97
145
  const sr = this._sr
@@ -100,24 +148,36 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
100
148
  let canChainToTargetVm = true
101
149
 
102
150
  await asyncEach(
103
- snapshotCandidates,
104
- async snapshot => {
151
+ snapshotCandidates.entries(),
152
+ async ([sourceUuid, snapshot]) => {
105
153
  let diffDisk
106
154
  let activeVdi
107
155
  try {
108
156
  activeVdi = sr.$xapi.getObject(snapshot.$snapshot_of)
109
157
  const userVbds = activeVdi.$VBDs?.filter(vbd => vbd.$VM && !vbd.$VM.is_control_domain) ?? []
110
158
  if (userVbds.length !== 1) {
159
+ canChainToTargetVm = false
111
160
  debug('checkBaseVdis, shared vbd ', { ref: snapshot.$ref, userVbds })
112
- // shared vdi ignore
161
+ // shared vdi ignore / don't chain
113
162
  return
114
163
  }
115
164
  const vm = userVbds[0].$VM
116
- if (!('start' in vm.blocked_operations)) {
117
- debug('checkBaseVdis, vm not blocked', { vmRef: vm.$ref })
118
- // vm start unlocked
119
- // not really an issue since we have check the delta
120
- // but it indicates the users played with the blocked operations
165
+
166
+ // a running VM will fail to compute disk exports
167
+ // also a running VM can be assumed to have changed data
168
+ if (vm.power_state !== VM_POWER_STATE.HALTED && vm.power_state !== VM_POWER_STATE.SUSPENDED) {
169
+ canChainToTargetVm = false
170
+ debug('checkBaseVdis, target vm is not halted or suspended', {
171
+ ref: snapshot.$ref,
172
+ userVbds,
173
+ powerState: vm.power_state,
174
+ })
175
+ }
176
+ // from this disk of from another
177
+ // skip the costly part, only do the disk chaining
178
+ if (!canChainToTargetVm) {
179
+ debug("Can't chain VM anyway , fast return and chain with snapshot")
180
+ baseVdisBySourceUuid.set(sourceUuid, snapshot)
121
181
  return
122
182
  }
123
183
  diffDisk = new XapiDiskSource({
@@ -127,18 +187,16 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
127
187
  onlyListChangedBlocks: true,
128
188
  })
129
189
  await diffDisk.init()
130
- const sourceUuid = snapshot.other_config?.[COPY_OF]
131
190
  if (diffDisk.getBlockIndexes().length === 0) {
191
+ debug(' NO CHANGE , source detected ? ', !!sourceUuid)
132
192
  // no block modification since the common snapshot, we can chain VM and disk
133
- if (sourceUuid) {
134
- baseVdisBySourceUuid.set(sourceUuid, activeVdi)
135
- }
193
+ // the disk is chained with the active to keep the chain linear
194
+ baseVdisBySourceUuid.set(sourceUuid, activeVdi)
136
195
  // Track the target VM (the replicated VM to update on the next transfer).
137
196
  targetVmRef = vm.$ref
138
197
  } else {
139
- if (sourceUuid) {
140
- baseVdisBySourceUuid.set(sourceUuid, snapshot)
141
- }
198
+ debug(' GOT CHANGE, source detected ? ', !!sourceUuid)
199
+ baseVdisBySourceUuid.set(sourceUuid, snapshot)
142
200
  // there are changed block since the snapshot
143
201
  // we can reuse it to transfer a delta, but we will
144
202
  // create a new VM
@@ -165,6 +223,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
165
223
  )
166
224
 
167
225
  if (!canChainToTargetVm) {
226
+ debug('checkBaseVdis,NOT a valid vm target')
168
227
  // if at least one disk has new data, create a new VM
169
228
  // instead of updating it
170
229
  targetVmRef = undefined
@@ -172,6 +231,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
172
231
  debug('checkBaseVdis,got a valid vm target', targetVmRef)
173
232
  }
174
233
 
234
+ debug('checkBaseVdis,base vdis found : ', baseVdisBySourceUuid.size)
175
235
  return { baseVdisBySourceUuid, targetVmRef }
176
236
  }
177
237
 
@@ -269,6 +329,10 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
269
329
  }
270
330
 
271
331
  Object.values(backup.vdis).forEach(vdi => {
332
+ // setVmSnapshotContentKeys sets CONTENT_KEY = the snapshot VDI's own UUID, but
333
+ // that XenAPI write may not be visible in the xapi cache yet when exportIncrementalVm
334
+ // reads vdi.other_config. Derive the correct value directly instead of relying on cache.
335
+ vdi.other_config[CONTENT_KEY] = vdi.uuid
272
336
  vdi.other_config[COPY_OF] = vdi.uuid
273
337
  vdi.other_config[JOB_ID] = job.id
274
338
  vdi.other_config[SCHEDULE_ID] = scheduleId
@@ -327,6 +391,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
327
391
  await xapi.VM_snapshot(targetVmRef, {
328
392
  name_label: `${vm.name_label} - ${job.name} / ${schedule.name} ${formatFilenameDate(timestamp)}`,
329
393
  })
394
+ await resetVmOtherConfig(xapi, targetVmRef)
330
395
  })
331
396
 
332
397
  return {
@@ -31,7 +31,6 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
31
31
  return await Task.run({ properties: { name: 'clean-vm' } }, () => {
32
32
  return this._adapter.cleanVm(this._vmBackupDir, {
33
33
  ...options,
34
- fixMetadata: true,
35
34
  logInfo: info,
36
35
  logWarn: (message, data) => {
37
36
  warn(message, data)
@@ -39,7 +38,6 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
39
38
  },
40
39
  lock: false,
41
40
  mergeBlockConcurrency: this._config.mergeBlockConcurrency,
42
- removeTmp: true,
43
41
  })
44
42
  })
45
43
  } catch (error) {
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.72.1",
11
+ "version": "0.73.0",
12
12
  "engines": {
13
13
  "node": ">=14.18"
14
14
  },
@@ -30,12 +30,14 @@
30
30
  "@vates/nbd-client": "^3.4.0",
31
31
  "@vates/parse-duration": "^0.1.1",
32
32
  "@vates/task": "^0.7.0",
33
+ "@vates/types": "^1.25.0",
33
34
  "@xen-orchestra/async-map": "^0.1.3",
34
- "@xen-orchestra/disk-transform": "^1.2.3",
35
- "@xen-orchestra/fs": "^4.8.0",
35
+ "@xen-orchestra/disk-transform": "^1.3.0",
36
+ "@xen-orchestra/fs": "^4.9.0",
36
37
  "@xen-orchestra/log": "^0.7.2",
37
38
  "@xen-orchestra/qcow2": "^1.3.0",
38
39
  "@xen-orchestra/template": "^0.1.1",
40
+ "@xen-orchestra/backup-archive": "^2.0.0",
39
41
  "app-conf": "^3.0.0",
40
42
  "compare-versions": "^6.0.0",
41
43
  "d3-time-format": "^4.1.0",
@@ -61,10 +63,11 @@
61
63
  "fs-extra": "^11.1.0",
62
64
  "rimraf": "^6.0.1",
63
65
  "sinon": "^18.0.0",
64
- "tmp": "^0.2.1"
66
+ "tmp": "^0.2.1",
67
+ "typescript": "^5.9.3"
65
68
  },
66
69
  "peerDependencies": {
67
- "@xen-orchestra/xapi": "^8.7.2"
70
+ "@xen-orchestra/xapi": "^8.8.0"
68
71
  },
69
72
  "license": "AGPL-3.0-or-later",
70
73
  "author": {
@@ -72,7 +75,6 @@
72
75
  "url": "https://vates.fr"
73
76
  },
74
77
  "exports": {
75
- "./disks": "./disks/index.mjs",
76
78
  "./*": "./*"
77
79
  }
78
80
  }