@xen-orchestra/backups 0.72.0 → 0.72.1

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.
package/RemoteAdapter.mjs CHANGED
@@ -702,7 +702,7 @@ export class RemoteAdapter {
702
702
  const handler = this._handler
703
703
 
704
704
  if (this.useVhdDirectory()) {
705
- await writeToVhdDirectory({
705
+ return await writeToVhdDirectory({
706
706
  disk,
707
707
  target: {
708
708
  handler,
@@ -714,8 +714,9 @@ export class RemoteAdapter {
714
714
  })
715
715
  } else {
716
716
  const stream = await toVhdStream(disk)
717
- await this.outputStream(path, stream, { validator, checksum: false })
717
+ const size = await this.outputStream(path, stream, { validator, checksum: false })
718
718
  await validator(path)
719
+ return size
719
720
  }
720
721
  }
721
722
 
package/_cleanVm.mjs CHANGED
@@ -586,18 +586,13 @@ export async function cleanVm(
586
586
  return
587
587
  }
588
588
 
589
- // systematically update size and differentials after a merge
590
-
591
- // @todo : after 2024-04-01 remove the fixmetadata options since the size computation is fixed
592
- if (mergedSize || (fixMetadata && fileSystemSize !== size)) {
593
- metadata.size = mergedSize ?? fileSystemSize ?? size
594
-
595
- if (mergedSize) {
596
- // all disks are now key disk
597
- metadata.isVhdDifferencing = {}
598
- for (const id of Object.keys(metadata.vdis ?? {})) {
599
- metadata.isVhdDifferencing[id] = false
600
- }
589
+ // Rewrite metadata when a merge changed the backup layout.
590
+ if (mergedSize) {
591
+ metadata.size = mergedSize
592
+ // all disks are now key disk
593
+ metadata.isVhdDifferencing = {}
594
+ for (const id of Object.keys(metadata.vdis ?? {})) {
595
+ metadata.isVhdDifferencing[id] = false
601
596
  }
602
597
  mustRegenerateCache = true
603
598
  try {
@@ -338,6 +338,20 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
338
338
  }
339
339
 
340
340
  this._jobSnapshotVdis = Object.values(vdiCandidates)
341
+
342
+ // For VMs with no disks, retention must be tracked directly on VM snapshots
343
+ // since there are no VDIs to anchor the discovery.
344
+ 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
+ )
352
+ } else {
353
+ this._disklessJobSnapshotVms = []
354
+ }
341
355
  }
342
356
 
343
357
  async _removeUnusedSnapshots() {
@@ -349,9 +363,10 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
349
363
  await xapi.barrier()
350
364
  // ensure cached object are up to date
351
365
  this._jobSnapshotVdis = this._jobSnapshotVdis.map(vdi => xapi.getObject(vdi.$ref))
366
+ const disklessVmSnapshots = this._disklessJobSnapshotVms.map(vm => xapi.getObject(vm.$ref))
352
367
 
353
- // get the datetime of the most recent snapshot
354
- const lastSnapshotDateTime = this._jobSnapshotVdis
368
+ // get the datetime of the most recent snapshot across both VDI and diskless VM snapshots
369
+ const lastSnapshotDateTime = [...this._jobSnapshotVdis, ...disklessVmSnapshots]
355
370
  .map(({ other_config }) => other_config[DATETIME])
356
371
  .sort()
357
372
  .pop()
@@ -428,6 +443,28 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
428
443
  })
429
444
  })
430
445
 
446
+ // Retention for VMs with no disks: VM snapshots are not reachable via VDIs
447
+ if (disklessVmSnapshots.length > 0) {
448
+ const snapshotsPerSchedule = groupBy(disklessVmSnapshots, _ => _.other_config[SCHEDULE_ID])
449
+ await asyncEach(Object.entries(snapshotsPerSchedule), async ([scheduleId, snapshots]) => {
450
+ // we only have one snapshot per date time since it's at the VM level
451
+ const snapshotPerDatetime = Object.fromEntries(snapshots.map(s => [s.other_config[DATETIME], s.$ref]))
452
+ const datetimes = Object.keys(snapshotPerDatetime).sort()
453
+ const settings = {
454
+ ...baseSettings,
455
+ ...allSettings[scheduleId],
456
+ ...allSettings[this._vm.uuid],
457
+ }
458
+ const retention = settings.snapshotRetention ?? 0
459
+ await asyncEach(getOldEntries(retention, datetimes), async datetime => {
460
+ if (this.job.mode === 'delta' && datetime === lastSnapshotDateTime) {
461
+ return
462
+ }
463
+ await xapi.VM_destroy(snapshotPerDatetime[datetime])
464
+ })
465
+ })
466
+ }
467
+
431
468
  // list and remove the snapshot were the jobs failed between
432
469
  // makesnapshot and update_other_config
433
470
  const snapshots = this._vm.$snapshots.filter(_ => !!_).filter(({ name_label }) => name_label === TEMP_SNAPSHOT_NAME)
@@ -229,14 +229,13 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
229
229
  Object.entries(deltaExport.disks),
230
230
  async ([diskRef, disk]) => {
231
231
  const path = `${this._vmBackupDir}/${vhds[diskRef]}`
232
- await adapter.writeVhd(path, disk, {
232
+ size += await adapter.writeVhd(path, disk, {
233
233
  // no checksum for VHDs, because they will be invalidated by
234
234
  // merges and chains
235
235
  checksum: false,
236
236
  validator: tmpPath => checkVhd(handler, tmpPath),
237
237
  writeBlockConcurrency: this._config.writeBlockConcurrency,
238
238
  })
239
- size = size + disk.getNbGeneratedBlock() * disk.getBlockSize()
240
239
  },
241
240
  {
242
241
  concurrency: settings.diskPerVmConcurrency,
@@ -57,71 +57,14 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
57
57
  debug('checkBaseVdis, got snapshot candidates,', snapshotCandidates.length)
58
58
 
59
59
  if (snapshotCandidates.length > 0) {
60
- // New snapshot-based flow (6.3+): verify no data was written between
61
- // the target snapshot and its active VDI.
62
- let targetVmRef
63
- let canChainToTargetVm = true
64
- await asyncEach(
65
- snapshotCandidates,
66
- async snapshot => {
67
- let diffDisk
68
- let activeVdi
69
- try {
70
- activeVdi = sr.$xapi.getObject(snapshot.$snapshot_of)
71
- const userVbds = activeVdi.$VBDs?.filter(vbd => vbd.$VM && !vbd.$VM.is_control_domain) ?? []
72
- if (userVbds.length !== 1) {
73
- debug('checkBaseVdis, share vbd ', { ref: snapshot.$ref, userVbds })
74
- // shared vdi ignore
75
- return
76
- }
77
- const vm = userVbds[0].$VM
78
- if (!('start' in vm.blocked_operations)) {
79
- debug('checkBaseVdis, vm not blocked', { vmRef: vm.$ref })
80
- // vm start unlocked
81
- // not really an issue since we have check the delta
82
- // but it indicates the users played with the blocked operations
83
- return
84
- }
85
- diffDisk = new XapiDiskSource({
86
- xapi: sr.$xapi,
87
- vdiRef: activeVdi.$ref,
88
- baseRef: snapshot.$ref,
89
- onlyListChangedBlocks: true,
90
- })
91
- await diffDisk.init()
92
- if (diffDisk.getBlockIndexes().length === 0) {
93
- const sourceUuid = snapshot.other_config?.[COPY_OF]
94
- if (sourceUuid) {
95
- this.#baseVdisBySourceUuid.set(sourceUuid, activeVdi)
96
- }
97
- // Track the target VM (the replicated VM to update on the next transfer).
98
- targetVmRef = vm.$ref
99
- } else {
100
- // not empty, we will create a new VM
101
- canChainToTargetVm = false
102
- debug('checkBaseVdis, data between snapshot and active disk', {
103
- vdiRef: snapshot.$ref,
104
- nbBlocks: diffDisk.getBlockIndexes().length,
105
- })
106
- }
107
- } catch (error) {
108
- debug('checkBaseVdis, skipping snapshot', { ref: snapshot.$ref, error })
109
- return
110
- } finally {
111
- await diffDisk?.close().catch(error => debug('checkBaseVdis, error closing', error))
112
- await sr.$xapi.VDI_disconnectFromControlDomain(snapshot.$ref)
113
- if (activeVdi !== undefined) {
114
- await sr.$xapi.VDI_disconnectFromControlDomain(activeVdi.$ref)
115
- }
116
- }
117
- },
118
- {
119
- concurrency: 4,
120
- }
121
- )
122
-
123
- if (canChainToTargetVm && targetVmRef !== undefined) {
124
- debug('checkBaseVdis,got a valid vm target', targetVmRef)
60
+ // reset before searching for candidates
61
+ this.#baseVdisBySourceUuid = new Map()
62
+ this._targetVmRef = undefined
63
+ const { baseVdisBySourceUuid, targetVmRef } = await this.#validateSnapshotCandidates(snapshotCandidates)
64
+ for (const [sourceUuid, vdi] of baseVdisBySourceUuid) {
65
+ this.#baseVdisBySourceUuid.set(sourceUuid, vdi)
66
+ }
67
+ if (targetVmRef !== undefined) {
125
68
  this._targetVmRef = targetVmRef
126
69
  }
127
70
  } else {
@@ -145,6 +88,93 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
145
88
  }
146
89
  }
147
90
  }
91
+ /**
92
+ * 6.3+ snapshot-based validation: for each snapshot candidate, check whether
93
+ * the active VDI has diverged from the snapshot. Returns a baseVdisBySourceUuid
94
+ * map and, when all disks are clean, the targetVmRef to reuse.
95
+ */
96
+ async #validateSnapshotCandidates(snapshotCandidates) {
97
+ const sr = this._sr
98
+ const baseVdisBySourceUuid = new Map()
99
+ let targetVmRef
100
+ let canChainToTargetVm = true
101
+
102
+ await asyncEach(
103
+ snapshotCandidates,
104
+ async snapshot => {
105
+ let diffDisk
106
+ let activeVdi
107
+ try {
108
+ activeVdi = sr.$xapi.getObject(snapshot.$snapshot_of)
109
+ const userVbds = activeVdi.$VBDs?.filter(vbd => vbd.$VM && !vbd.$VM.is_control_domain) ?? []
110
+ if (userVbds.length !== 1) {
111
+ debug('checkBaseVdis, shared vbd ', { ref: snapshot.$ref, userVbds })
112
+ // shared vdi ignore
113
+ return
114
+ }
115
+ 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
121
+ return
122
+ }
123
+ diffDisk = new XapiDiskSource({
124
+ xapi: sr.$xapi,
125
+ vdiRef: activeVdi.$ref,
126
+ baseRef: snapshot.$ref,
127
+ onlyListChangedBlocks: true,
128
+ })
129
+ await diffDisk.init()
130
+ const sourceUuid = snapshot.other_config?.[COPY_OF]
131
+ if (diffDisk.getBlockIndexes().length === 0) {
132
+ // no block modification since the common snapshot, we can chain VM and disk
133
+ if (sourceUuid) {
134
+ baseVdisBySourceUuid.set(sourceUuid, activeVdi)
135
+ }
136
+ // Track the target VM (the replicated VM to update on the next transfer).
137
+ targetVmRef = vm.$ref
138
+ } else {
139
+ if (sourceUuid) {
140
+ baseVdisBySourceUuid.set(sourceUuid, snapshot)
141
+ }
142
+ // there are changed block since the snapshot
143
+ // we can reuse it to transfer a delta, but we will
144
+ // create a new VM
145
+ canChainToTargetVm = false
146
+ debug('checkBaseVdis, data between snapshot and active disk', {
147
+ vdiRef: snapshot.$ref,
148
+ nbBlocks: diffDisk.getBlockIndexes().length,
149
+ })
150
+ }
151
+ } catch (error) {
152
+ debug('checkBaseVdis, skipping snapshot', { ref: snapshot.$ref, error })
153
+ return
154
+ } finally {
155
+ await diffDisk?.close().catch(error => debug('checkBaseVdis, error closing', error))
156
+ await sr.$xapi.VDI_disconnectFromControlDomain(snapshot.$ref)
157
+ if (activeVdi !== undefined) {
158
+ await sr.$xapi.VDI_disconnectFromControlDomain(activeVdi.$ref)
159
+ }
160
+ }
161
+ },
162
+ {
163
+ concurrency: 4,
164
+ }
165
+ )
166
+
167
+ if (!canChainToTargetVm) {
168
+ // if at least one disk has new data, create a new VM
169
+ // instead of updating it
170
+ targetVmRef = undefined
171
+ } else if (targetVmRef !== undefined) {
172
+ debug('checkBaseVdis,got a valid vm target', targetVmRef)
173
+ }
174
+
175
+ return { baseVdisBySourceUuid, targetVmRef }
176
+ }
177
+
148
178
  updateUuidAndChain() {
149
179
  // nothing to do, the chaining is not modified in this case
150
180
  }
@@ -222,6 +222,7 @@ export class MergeRemoteDisk {
222
222
  await this.#mergeBlocks(parentDisk, childDisk)
223
223
  await parentDisk.flushMetadata(childDisk)
224
224
  await parentDisk.mergeMetadata(childDisk)
225
+ this.#state.diskSize = parentDisk.getSizeOnDisk()
225
226
  }
226
227
 
227
228
  /**
@@ -266,8 +267,6 @@ export class MergeRemoteDisk {
266
267
 
267
268
  await this.#writeState()
268
269
 
269
- this.#state.diskSize = childDisk.getSizeOnDisk()
270
-
271
270
  this.#onProgress({ total: nBlocks, done: nBlocks })
272
271
  }
273
272
 
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.0",
11
+ "version": "0.72.1",
12
12
  "engines": {
13
13
  "node": ">=14.18"
14
14
  },