@xen-orchestra/backups 0.72.0 → 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
 
@@ -702,7 +701,7 @@ export class RemoteAdapter {
702
701
  const handler = this._handler
703
702
 
704
703
  if (this.useVhdDirectory()) {
705
- await writeToVhdDirectory({
704
+ return await writeToVhdDirectory({
706
705
  disk,
707
706
  target: {
708
707
  handler,
@@ -714,8 +713,9 @@ export class RemoteAdapter {
714
713
  })
715
714
  } else {
716
715
  const stream = await toVhdStream(disk)
717
- await this.outputStream(path, stream, { validator, checksum: false })
716
+ const size = await this.outputStream(path, stream, { validator, checksum: false })
718
717
  await validator(path)
718
+ return size
719
719
  }
720
720
  }
721
721
 
@@ -878,11 +878,14 @@ export class RemoteAdapter {
878
878
  }
879
879
 
880
880
  Object.assign(RemoteAdapter.prototype, {
881
- cleanVm(vmDir, { lock = true } = {}) {
881
+ cleanVm(vmBackupPath, opts = {}) {
882
+ const { lock = true, ...cleanOpts } = opts
882
883
  if (lock) {
883
- 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
+ })
884
887
  } else {
885
- return cleanVm.apply(this, arguments)
888
+ return VmBackupDirectory.cleanVm(this._handler, vmBackupPath, cleanOpts)
886
889
  }
887
890
  },
888
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,8 +315,47 @@ 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)
343
+
344
+ // For VMs with no disks, retention must be tracked directly on VM snapshots
345
+ // since there are no VDIs to anchor the discovery.
346
+ if (vdiUuids.length === 0) {
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
+ )
356
+ } else {
357
+ this._disklessJobSnapshotVms = []
358
+ }
341
359
  }
342
360
 
343
361
  async _removeUnusedSnapshots() {
@@ -349,9 +367,10 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
349
367
  await xapi.barrier()
350
368
  // ensure cached object are up to date
351
369
  this._jobSnapshotVdis = this._jobSnapshotVdis.map(vdi => xapi.getObject(vdi.$ref))
370
+ const disklessVmSnapshots = this._disklessJobSnapshotVms.map(vm => xapi.getObject(vm.$ref))
352
371
 
353
- // get the datetime of the most recent snapshot
354
- const lastSnapshotDateTime = this._jobSnapshotVdis
372
+ // get the datetime of the most recent snapshot across both VDI and diskless VM snapshots
373
+ const lastSnapshotDateTime = [...this._jobSnapshotVdis, ...disklessVmSnapshots]
355
374
  .map(({ other_config }) => other_config[DATETIME])
356
375
  .sort()
357
376
  .pop()
@@ -428,6 +447,28 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
428
447
  })
429
448
  })
430
449
 
450
+ // Retention for VMs with no disks: VM snapshots are not reachable via VDIs
451
+ if (disklessVmSnapshots.length > 0) {
452
+ const snapshotsPerSchedule = groupBy(disklessVmSnapshots, _ => _.other_config[SCHEDULE_ID])
453
+ await asyncEach(Object.entries(snapshotsPerSchedule), async ([scheduleId, snapshots]) => {
454
+ // we only have one snapshot per date time since it's at the VM level
455
+ const snapshotPerDatetime = Object.fromEntries(snapshots.map(s => [s.other_config[DATETIME], s.$ref]))
456
+ const datetimes = Object.keys(snapshotPerDatetime).sort()
457
+ const settings = {
458
+ ...baseSettings,
459
+ ...allSettings[scheduleId],
460
+ ...allSettings[this._vm.uuid],
461
+ }
462
+ const retention = settings.snapshotRetention ?? 0
463
+ await asyncEach(getOldEntries(retention, datetimes), async datetime => {
464
+ if (this.job.mode === 'delta' && datetime === lastSnapshotDateTime) {
465
+ return
466
+ }
467
+ await xapi.VM_destroy(snapshotPerDatetime[datetime])
468
+ })
469
+ })
470
+ }
471
+
431
472
  // list and remove the snapshot were the jobs failed between
432
473
  // makesnapshot and update_other_config
433
474
  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,