@xen-orchestra/backups 0.49.0 → 0.50.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.
package/_cleanVm.mjs CHANGED
@@ -3,7 +3,7 @@ import sum from 'lodash/sum.js'
3
3
  import { asyncMap } from '@xen-orchestra/async-map'
4
4
  import { Constants, openVhd, VhdAbstract, VhdFile } from 'vhd-lib'
5
5
  import { isVhdAlias, resolveVhdAlias } from 'vhd-lib/aliases.js'
6
- import { dirname, resolve } from 'node:path'
6
+ import { basename, dirname, resolve } from 'node:path'
7
7
  import { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } from './_backupType.mjs'
8
8
  import { limitConcurrency } from 'limit-concurrency-decorator'
9
9
  import { mergeVhdChain } from 'vhd-lib/merge.js'
@@ -179,7 +179,8 @@ export async function cleanVm(
179
179
  vmDir,
180
180
  {
181
181
  fixMetadata,
182
- remove,
182
+ remove = false,
183
+ removeTmp = remove,
183
184
  merge,
184
185
  mergeBlockConcurrency,
185
186
  mergeLimiter = defaultMergeLimiter,
@@ -200,6 +201,10 @@ export async function cleanVm(
200
201
 
201
202
  // remove broken VHDs
202
203
  await asyncMap(vhds, async path => {
204
+ if(removeTmp && basename(path)[0] === '.'){
205
+ logInfo('deleting temporary VHD', { path })
206
+ return VhdAbstract.unlink(handler, path)
207
+ }
203
208
  try {
204
209
  await Disposable.use(openVhd(handler, path, { checkSecondFooter: !interruptedVhds.has(path) }), vhd => {
205
210
  if (vhd.footer.diskType === DISK_TYPES.DIFFERENCING) {
@@ -1,6 +1,5 @@
1
1
  import groupBy from 'lodash/groupBy.js'
2
2
  import ignoreErrors from 'promise-toolbox/ignoreErrors'
3
- import omit from 'lodash/omit.js'
4
3
  import { asyncMap } from '@xen-orchestra/async-map'
5
4
  import { CancelToken } from 'promise-toolbox'
6
5
  import { compareVersions } from 'compare-versions'
@@ -10,43 +9,16 @@ import { defer } from 'golike-defer'
10
9
  import { cancelableMap } from './_cancelableMap.mjs'
11
10
  import { Task } from './Task.mjs'
12
11
  import pick from 'lodash/pick.js'
13
-
14
- // in `other_config` of an incrementally replicated VM, contains the UUID of the source VM
15
- export const TAG_BASE_DELTA = 'xo:base_delta'
16
-
17
- // in `other_config` of an incrementally replicated VM, contains the UUID of the target SR used for replication
18
- //
19
- // added after the complete replication
20
- export const TAG_BACKUP_SR = 'xo:backup:sr'
21
-
22
- // in other_config of VDIs of an incrementally replicated VM, contains the UUID of the source VDI
23
- export const TAG_COPY_SRC = 'xo:copy_of'
12
+ import { BASE_DELTA_VDI, COPY_OF, VM_UUID } from './_otherConfig.mjs'
24
13
 
25
14
  const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value])
26
15
 
27
16
  export async function exportIncrementalVm(
28
17
  vm,
29
- baseVm,
30
- {
31
- cancelToken = CancelToken.none,
32
-
33
- // Sets of UUIDs of VDIs that must be exported as full.
34
- fullVdisRequired = new Set(),
35
-
36
- disableBaseTags = false,
37
- nbdConcurrency = 1,
38
- preferNbd,
39
- } = {}
18
+ baseVdis = {},
19
+ { cancelToken = CancelToken.none, nbdConcurrency = 1, preferNbd } = {}
40
20
  ) {
41
21
  // refs of VM's VDIs → base's VDIs.
42
- const baseVdis = {}
43
- baseVm &&
44
- baseVm.$VBDs.forEach(vbd => {
45
- let vdi, snapshotOf
46
- if ((vdi = vbd.$VDI) && (snapshotOf = vdi.$snapshot_of) && !fullVdisRequired.has(snapshotOf.uuid)) {
47
- baseVdis[vdi.snapshot_of] = vdi
48
- }
49
- })
50
22
 
51
23
  const streams = {}
52
24
  const vdis = {}
@@ -67,13 +39,16 @@ export async function exportIncrementalVm(
67
39
  }
68
40
 
69
41
  // Look for a snapshot of this vdi in the base VM.
70
- const baseVdi = baseVdis[vdi.snapshot_of]
42
+ const baseVdi = baseVdis[vdi.$snapshot_of.uuid]
71
43
 
72
44
  vdis[vdiRef] = {
73
45
  ...vdi,
74
46
  other_config: {
75
47
  ...vdi.other_config,
76
- [TAG_BASE_DELTA]: baseVdi && !disableBaseTags ? baseVdi.uuid : undefined,
48
+ [BASE_DELTA_VDI]: baseVdi?.uuid,
49
+ [VM_UUID]:
50
+ vm.$snapshot_of?.uuid ?? // vm is a snapshot
51
+ vm.uuid, // vm is a not snapshot
77
52
  },
78
53
  $snapshot_of$uuid: vdi.$snapshot_of?.uuid,
79
54
  $SR$uuid: vdi.$SR.uuid,
@@ -120,13 +95,6 @@ export async function exportIncrementalVm(
120
95
  vifs,
121
96
  vm: {
122
97
  ...vm,
123
- other_config:
124
- baseVm && !disableBaseTags
125
- ? {
126
- ...vm.other_config,
127
- [TAG_BASE_DELTA]: baseVm.uuid,
128
- }
129
- : omit(vm.other_config, TAG_BASE_DELTA),
130
98
  },
131
99
  },
132
100
  'streams',
@@ -205,7 +173,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
205
173
  newVdi = await xapi.getRecord('VDI', await vdi.baseVdi.$clone())
206
174
  $defer.onFailure(() => newVdi.$destroy())
207
175
 
208
- await newVdi.update_other_config(TAG_COPY_SRC, vdi.uuid)
176
+ await newVdi.update_other_config(COPY_OF, vdi.uuid)
209
177
  if (vdi.virtual_size > newVdi.virtual_size) {
210
178
  await newVdi.$callAsync('resize', vdi.virtual_size)
211
179
  }
@@ -0,0 +1,165 @@
1
+ import { formatDateTime } from '@xen-orchestra/xapi'
2
+ import assert from 'node:assert/strict'
3
+ // in `other_config` of an incrementally replicated VM or VDI
4
+ // contains the UUID of the object used as a base for an incremental export
5
+ // used to search for the replica of the base before applying a incremental replication
6
+ export const BASE_DELTA_VDI = 'xo:base_delta_vdi'
7
+
8
+ // in `other_config` of an incrementally replicated VM, contains the UUID of the target SR used for replication
9
+ //
10
+ // added after the complete replication
11
+ export const REPLICATED_TO_SR_UUID = 'xo:backup:sr'
12
+
13
+ // in other_config of VDIs of an incrementally replicated VM
14
+ // contains the UUID of the source exported object (snapshot or VM)
15
+
16
+ export const COPY_OF = 'xo:copy_of'
17
+
18
+ export const DATETIME = 'xo:backup:datetime'
19
+
20
+ export const JOB_ID = 'xo:backup:job'
21
+ export const SCHEDULE_ID = 'xo:backup:schedule'
22
+
23
+ // contains the number of delta in a chain, stored as a string
24
+ export const DELTA_CHAIN_LENGTH = 'xo:backup:deltaChainLength'
25
+ // contains the string true if this vdi has been exported successfully
26
+ export const EXPORTED_SUCCESSFULLY = 'xo:backup:exported'
27
+
28
+ // the VM ( not the snapshot) uuid
29
+ export const VM_UUID = 'xo:backup:vm'
30
+
31
+ async function listVdiRefs(xapi, vmRef) {
32
+ return xapi.VM_getDisks(vmRef)
33
+ }
34
+
35
+ async function applyToVmAndVdis(xapi, vmRef, fn) {
36
+ const vdiRefs = await listVdiRefs(xapi, vmRef)
37
+ return Promise.all([fn('VM', vmRef), ...vdiRefs.map(vdiRef => fn('VDI', vdiRef))])
38
+ }
39
+
40
+ async function getDeltaChainLength(xapi, type, ref) {
41
+ const otherConfig = await xapi.getField(type, ref, 'other_config')
42
+ return Number(otherConfig[DELTA_CHAIN_LENGTH] ?? 0)
43
+ }
44
+
45
+ /**
46
+ * set the delta chain lenght ( number of delta since last base backup) to a VM and its associated VDIs
47
+ *
48
+ * @param {Xapi} xapi
49
+ * @param {String} vmRef
50
+ * @param {Number} length
51
+ * @returns {Promise}
52
+ */
53
+ export async function setVmDeltaChainLength(xapi, vmRef, length) {
54
+ return applyToVmAndVdis(xapi, vmRef, async (type, ref) => {
55
+ await xapi.setFieldEntry(type, ref, 'other_config', DELTA_CHAIN_LENGTH, String(length))
56
+ })
57
+ }
58
+
59
+ /**
60
+ * Compute the delta chain length of a VM and its associated VDIs
61
+ * if there is a discrependcy, use, the highest value
62
+ * @param {Xapi} xapi
63
+ * @param {String} vmRef
64
+ * @returns {Promise}
65
+ */
66
+ export async function getVmDeltaChainLength(xapi, vmRef) {
67
+ const lengths = await applyToVmAndVdis(xapi, vmRef, async (type, ref) => getDeltaChainLength(xapi, type, ref))
68
+ return Math.max(...lengths)
69
+ }
70
+
71
+ /**
72
+ *
73
+ * Reset the other_config field of a VM and its VDIs
74
+ *
75
+ * @param {Xapi} xapi
76
+ * @param {String} vmRef
77
+ * @returns {Promise}
78
+ */
79
+ export function resetVmOtherConfig(xapi, vmRef) {
80
+ return applyToVmAndVdis(xapi, vmRef, (type, ref) => {
81
+ return xapi.setFieldEntries(type, ref, 'other_config', {
82
+ [DATETIME]: null,
83
+ [DELTA_CHAIN_LENGTH]: null,
84
+ [EXPORTED_SUCCESSFULLY]: null,
85
+ [JOB_ID]: null,
86
+ [SCHEDULE_ID]: null,
87
+ [VM_UUID]: null,
88
+ // REPLICATED_TO_SR_UUID is not reset since we can replicate a replication
89
+ })
90
+ })
91
+ }
92
+
93
+ /**
94
+ *
95
+ * used to ensure compatibiliy with the previous snapshots that were having the config stored only into VM
96
+ *
97
+ * @param {Xapi} xapi
98
+ * @param {String} vmRef
99
+ * @returns {Promise}
100
+ */
101
+ export async function populateVdisOtherConfig(xapi, vmRef) {
102
+ const otherConfig = await xapi.getField('VM', vmRef, 'other_config')
103
+ const {
104
+ [DATETIME]: datetime,
105
+ [DELTA_CHAIN_LENGTH]: chainLength,
106
+ [EXPORTED_SUCCESSFULLY]: successfully,
107
+ [JOB_ID]: jobId,
108
+ [REPLICATED_TO_SR_UUID]: replicatedTo,
109
+ [SCHEDULE_ID]: scheduleId,
110
+ [VM_UUID]: vmUuid,
111
+ } = otherConfig
112
+
113
+ return applyToVmAndVdis(xapi, vmRef, (type, ref) =>
114
+ xapi.setFieldEntries(type, ref, 'other_config', {
115
+ [DATETIME]: datetime,
116
+ [DELTA_CHAIN_LENGTH]: chainLength,
117
+ [EXPORTED_SUCCESSFULLY]: successfully,
118
+ [JOB_ID]: jobId,
119
+ [REPLICATED_TO_SR_UUID]: replicatedTo,
120
+ [SCHEDULE_ID]: scheduleId,
121
+ [VM_UUID]: vmUuid,
122
+ })
123
+ )
124
+ }
125
+
126
+ /**
127
+ *
128
+ * set the other_config key related to a backup of a VM and its associated VDIs
129
+ *
130
+ * @param {Xapi} xapi
131
+ * @param {String} vmRef
132
+ * @param {*} settings
133
+ * @returns {PRomise}
134
+ */
135
+ export async function setVmOtherConfig(xapi, vmRef, { timestamp, jobId, scheduleId, vmUuid, srUuid = null, ...other }) {
136
+ assert.notEqual(timestamp, undefined)
137
+ assert.notEqual(jobId, undefined)
138
+ assert.notEqual(scheduleId, undefined)
139
+ assert.notEqual(vmUuid, undefined)
140
+ // srUuid is nullish for backup
141
+ assert.equal(Object.keys(other).length, 0)
142
+
143
+ return applyToVmAndVdis(xapi, vmRef, (type, ref) =>
144
+ xapi.setFieldEntries(type, ref, 'other_config', {
145
+ [REPLICATED_TO_SR_UUID]: srUuid,
146
+ [DATETIME]: formatDateTime(timestamp),
147
+ [JOB_ID]: jobId,
148
+ [SCHEDULE_ID]: scheduleId,
149
+ [VM_UUID]: vmUuid,
150
+ })
151
+ )
152
+ }
153
+ /**
154
+ *
155
+ * mark the export of he VM and its VDIs as successfull
156
+ *
157
+ * @param {Xapi} xapi
158
+ * @param {String} vmRef
159
+ * @returns {Promise}
160
+ */
161
+ export async function markExportSuccessfull(xapi, vmRef) {
162
+ return applyToVmAndVdis(xapi, vmRef, (type, ref) =>
163
+ xapi.setFieldEntry(type, ref, 'other_config', EXPORTED_SUCCESSFULLY, 'true')
164
+ )
165
+ }
@@ -29,6 +29,10 @@ export function forkStreamUnpipe(source) {
29
29
  if (source.forks === 0) {
30
30
  debug('no more forks, destroying original stream')
31
31
  source.destroy(new Error('no more consumers for this stream'))
32
+ } else {
33
+ // a combination of stream.unpipe, onReadable and onData may stall stream here
34
+ // force it to flow again since we're piping it
35
+ source.resume()
32
36
  }
33
37
  })
34
38
  return fork
@@ -1,8 +1,6 @@
1
1
  import { asyncEach } from '@vates/async-each'
2
- import { asyncMap } from '@xen-orchestra/async-map'
3
2
  import { createLogger } from '@xen-orchestra/log'
4
3
  import { pipeline } from 'node:stream'
5
- import findLast from 'lodash/findLast.js'
6
4
  import isVhdDifferencingDisk from 'vhd-lib/isVhdDifferencingDisk.js'
7
5
  import keyBy from 'lodash/keyBy.js'
8
6
  import mapValues from 'lodash/mapValues.js'
@@ -15,9 +13,15 @@ import { IncrementalRemoteWriter } from '../_writers/IncrementalRemoteWriter.mjs
15
13
  import { IncrementalXapiWriter } from '../_writers/IncrementalXapiWriter.mjs'
16
14
  import { Task } from '../../Task.mjs'
17
15
  import { watchStreamSize } from '../../_watchStreamSize.mjs'
16
+ import {
17
+ DATETIME,
18
+ DELTA_CHAIN_LENGTH,
19
+ EXPORTED_SUCCESSFULLY,
20
+ setVmDeltaChainLength,
21
+ markExportSuccessfull,
22
+ } from '../../_otherConfig.mjs'
18
23
 
19
24
  const { debug } = createLogger('xo:backups:IncrementalXapiVmBackup')
20
-
21
25
  const noop = Function.prototype
22
26
 
23
27
  export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends AbstractXapi {
@@ -30,17 +34,14 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
30
34
  }
31
35
 
32
36
  async _copy() {
33
- const baseVm = this._baseVm
37
+ const baseVdis = this._baseVdis
34
38
  const vm = this._vm
35
39
  const exportedVm = this._exportedVm
36
- const fullVdisRequired = this._fullVdisRequired
37
-
38
- const isFull = fullVdisRequired === undefined || fullVdisRequired.size !== 0
39
40
 
41
+ const isFull = Object.values(baseVdis).length === 0 || Object.values(baseVdis).some(_ => !_)
40
42
  await this._callWriters(writer => writer.prepare({ isFull }), 'writer.prepare()')
41
43
 
42
- const deltaExport = await exportIncrementalVm(exportedVm, baseVm, {
43
- fullVdisRequired,
44
+ const deltaExport = await exportIncrementalVm(exportedVm, baseVdis, {
44
45
  nbdConcurrency: this._settings.nbdConcurrency,
45
46
  preferNbd: this._settings.preferNbd,
46
47
  })
@@ -90,18 +91,13 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
90
91
  'writer.updateUuidAndChain()'
91
92
  )
92
93
 
93
- this._baseVm = exportedVm
94
-
95
- if (baseVm !== undefined) {
96
- await exportedVm.update_other_config(
97
- 'xo:backup:deltaChainLength',
98
- String(+(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1)
99
- )
100
- }
94
+ if (!isFull) {
95
+ await setVmDeltaChainLength(this._xapi, exportedVm.$ref, (this._deltaChainLength ?? 0) + 1)
96
+ } // on a full the delta chain will be null
101
97
 
102
98
  // not the case if offlineBackup
103
99
  if (exportedVm.is_a_snapshot) {
104
- await exportedVm.update_other_config('xo:backup:exported', 'true')
100
+ await markExportSuccessfull(this._xapi, exportedVm.$ref)
105
101
  }
106
102
 
107
103
  const size = Object.values(sizeContainers).reduce((sum, { size }) => sum + size, 0)
@@ -119,43 +115,62 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
119
115
  async _selectBaseVm() {
120
116
  const xapi = this._xapi
121
117
 
122
- let baseVm = findLast(this._jobSnapshots, _ => 'xo:backup:exported' in _.other_config)
123
- if (baseVm === undefined) {
124
- debug('no base VM found')
125
- return
118
+ // filter _jobSnapshotVdis to have only the last successfully exported vdi
119
+ // compute delta chain length
120
+ // fill baseUuidToSrcVdi with this data
121
+
122
+ const exportedVdis = this._jobSnapshotVdis.filter(
123
+ _ => EXPORTED_SUCCESSFULLY in _.other_config && DATETIME in _.other_config
124
+ )
125
+ let lastSuccessfullBackup
126
+ let lastExportedVdis = []
127
+ for (const exportedVdi of exportedVdis) {
128
+ if (lastSuccessfullBackup === undefined || lastSuccessfullBackup < exportedVdi.other_config[DATETIME]) {
129
+ lastExportedVdis = []
130
+ lastSuccessfullBackup = exportedVdi.other_config[DATETIME]
131
+ }
132
+ if (lastSuccessfullBackup === exportedVdi.other_config[DATETIME]) {
133
+ lastExportedVdis.push(exportedVdi)
134
+ }
126
135
  }
127
136
 
137
+ this._baseVdis = {}
138
+ if (lastExportedVdis.length === 0) {
139
+ debug('no base VDIS found', {
140
+ jobLength: this._jobSnapshotVdis.length,
141
+ lastSuccessfullBackup,
142
+ exportedLength: exportedVdis.length,
143
+ })
144
+ return
145
+ }
146
+ const deltaChainLength = Math.max(
147
+ ...lastExportedVdis.map(({ other_config }) => Number(other_config[DELTA_CHAIN_LENGTH] ?? 0))
148
+ )
128
149
  const fullInterval = this._settings.fullInterval
129
- const deltaChainLength = +(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1
130
- if (!(fullInterval === 0 || fullInterval > deltaChainLength)) {
131
- debug('not using base VM becaust fullInterval reached')
150
+ if ( fullInterval !== 0 && fullInterval < deltaChainLength + 1) {
151
+ debug('not using base VM because fullInterval reached', { fullInterval, deltaChainLength, eq: fullInterval < deltaChainLength + 1, dc1: deltaChainLength + 1 })
132
152
  return
133
153
  }
134
154
 
135
155
  const srcVdis = keyBy(await xapi.getRecords('VDI', await this._vm.$getDisks()), '$ref')
136
156
 
137
- // resolve full record
138
- baseVm = await xapi.getRecord('VM', baseVm.$ref)
139
-
140
- const baseUuidToSrcVdi = new Map()
141
- await asyncMap(await baseVm.$getDisks(), async baseRef => {
142
- const [baseUuid, snapshotOf] = await Promise.all([
143
- xapi.getField('VDI', baseRef, 'uuid'),
144
- xapi.getField('VDI', baseRef, 'snapshot_of'),
145
- ])
157
+ const baseUuidToSrcVdiUuid = new Map()
158
+ for (const lastExportedVdi of lastExportedVdis) {
159
+ const baseUuid = lastExportedVdi.uuid
160
+ const snapshotOf = lastExportedVdi.snapshot_of
146
161
  const srcVdi = srcVdis[snapshotOf]
147
162
  if (srcVdi !== undefined) {
148
- baseUuidToSrcVdi.set(baseUuid, srcVdi.uuid)
163
+ baseUuidToSrcVdiUuid.set(baseUuid, srcVdi.uuid)
149
164
  } else {
150
165
  debug('ignore snapshot VDI because no longer present on VM', {
151
166
  vdi: baseUuid,
152
167
  })
153
168
  }
154
- })
169
+ }
155
170
 
156
- const presentBaseVdis = new Map(baseUuidToSrcVdi)
171
+ const presentBaseVdis = new Map(baseUuidToSrcVdiUuid)
157
172
  await this._callWriters(
158
- writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis, baseVm),
173
+ writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis),
159
174
  'writer.checkBaseVdis()',
160
175
  false
161
176
  )
@@ -164,24 +179,22 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
164
179
  debug('no base VM found')
165
180
  return
166
181
  }
167
-
168
- const fullVdisRequired = new Set()
169
- baseUuidToSrcVdi.forEach((srcVdiUuid, baseUuid) => {
182
+ baseUuidToSrcVdiUuid.forEach((srcVdiUuid, baseUuid) => {
170
183
  if (presentBaseVdis.has(baseUuid)) {
171
184
  debug('found base VDI', {
172
185
  base: baseUuid,
173
186
  vdi: srcVdiUuid,
174
187
  })
188
+ this._baseVdis[srcVdiUuid] = lastExportedVdis.find(vdi => vdi.uuid === baseUuid)
175
189
  } else {
176
190
  debug('missing base VDI', {
177
191
  base: baseUuid,
178
192
  vdi: srcVdiUuid,
179
193
  })
180
- fullVdisRequired.add(srcVdiUuid)
194
+ // this will force a full for this vdi
195
+ this._baseVdis[srcVdiUuid] = undefined
181
196
  }
182
197
  })
183
-
184
- this._baseVm = baseVm
185
- this._fullVdisRequired = fullVdisRequired
198
+ this._deltaChainLength = deltaChainLength
186
199
  }
187
200
  }
@@ -4,11 +4,18 @@ import ignoreErrors from 'promise-toolbox/ignoreErrors'
4
4
  import { asyncMap } from '@xen-orchestra/async-map'
5
5
  import { decorateMethodsWith } from '@vates/decorate-with'
6
6
  import { defer } from 'golike-defer'
7
- import { formatDateTime } from '@xen-orchestra/xapi'
8
7
 
9
8
  import { getOldEntries } from '../../_getOldEntries.mjs'
10
9
  import { Task } from '../../Task.mjs'
11
10
  import { Abstract } from './_Abstract.mjs'
11
+ import {
12
+ DATETIME,
13
+ JOB_ID,
14
+ SCHEDULE_ID,
15
+ populateVdisOtherConfig,
16
+ resetVmOtherConfig,
17
+ setVmOtherConfig,
18
+ } from '../../_otherConfig.mjs'
12
19
 
13
20
  export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
14
21
  constructor({
@@ -25,7 +32,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
25
32
  vm,
26
33
  }) {
27
34
  super()
28
- if (vm.other_config['xo:backup:job'] === job.id && 'start' in vm.blocked_operations) {
35
+ if (vm.other_config[JOB_ID] === job.id && 'start' in vm.blocked_operations) {
29
36
  // don't match replicated VMs created by this very job otherwise they
30
37
  // will be replicated again and again
31
38
  throw new Error('cannot backup a VM created by this very job')
@@ -49,17 +56,17 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
49
56
  this._exportedVm = undefined
50
57
  this._vm = vm
51
58
 
52
- this._fullVdisRequired = undefined
59
+ this._baseVdis = undefined
53
60
  this._getSnapshotNameLabel = getSnapshotNameLabel
54
61
  this._isIncremental = job.mode === 'delta'
55
62
  this._healthCheckSr = healthCheckSr
56
63
  this._jobId = job.id
57
- this._jobSnapshots = undefined
64
+ this._jobSnapshotVdis = undefined
58
65
  this._throttleStream = throttleStream
59
66
  this._xapi = vm.$xapi
60
67
 
61
68
  // Base VM for the export
62
- this._baseVm = undefined
69
+ this._baseVdis = undefined
63
70
 
64
71
  // Settings for this specific run (job, schedule, VM)
65
72
  if (tags.includes('xo-memory-backup')) {
@@ -123,15 +130,8 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
123
130
  // copied on manual snapshots and interfere with the backup jobs
124
131
  async _cleanMetadata() {
125
132
  const vm = this._vm
126
- if ('xo:backup:job' in vm.other_config) {
127
- await vm.update_other_config({
128
- 'xo:backup:datetime': null,
129
- 'xo:backup:deltaChainLength': null,
130
- 'xo:backup:exported': null,
131
- 'xo:backup:job': null,
132
- 'xo:backup:schedule': null,
133
- 'xo:backup:vm': null,
134
- })
133
+ if (JOB_ID in vm.other_config) {
134
+ await resetVmOtherConfig(this._xapi, vm.$ref)
135
135
  }
136
136
  }
137
137
 
@@ -146,6 +146,15 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
146
146
  if (!settings.bypassVdiChainsCheck) {
147
147
  await vm.$assertHealthyVdiChains()
148
148
  }
149
+ if (settings.preferNbd) {
150
+ try {
151
+ // enable CBT on all disks if possible
152
+ const diskRefs = await xapi.VM_getDisks(vm.$ref)
153
+ await Promise.all(diskRefs.map(diskRef => xapi.call('VDI.enable_cbt', diskRef)))
154
+ } catch (error) {
155
+ Task.info(`couldn't enable CBT`, error)
156
+ }
157
+ }
149
158
 
150
159
  const snapshotRef = await vm[settings.checkpointSnapshot ? '$checkpoint' : '$snapshot']({
151
160
  ignoreNobakVdis: true,
@@ -153,16 +162,13 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
153
162
  unplugVusbs: true,
154
163
  })
155
164
  this.timestamp = Date.now()
156
-
157
- await xapi.setFieldEntries('VM', snapshotRef, 'other_config', {
158
- 'xo:backup:datetime': formatDateTime(this.timestamp),
159
- 'xo:backup:job': this._jobId,
160
- 'xo:backup:schedule': this.scheduleId,
161
- 'xo:backup:vm': vm.uuid,
165
+ await setVmOtherConfig(xapi, snapshotRef, {
166
+ timestamp: this.timestamp,
167
+ jobId: this._jobId,
168
+ scheduleId: this.scheduleId,
169
+ vmUuid: vm.uuid,
162
170
  })
163
-
164
171
  this._exportedVm = await xapi.getRecord('VM', snapshotRef)
165
-
166
172
  return this._exportedVm.uuid
167
173
  })
168
174
  } else {
@@ -176,38 +182,116 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
176
182
  const vmRef = this._vm.$ref
177
183
  const xapi = this._xapi
178
184
 
179
- const snapshotsRef = await xapi.getField('VM', vmRef, 'snapshots')
180
- const snapshotsOtherConfig = await asyncMap(snapshotsRef, ref => xapi.getField('VM', ref, 'other_config'))
185
+ // to ensure compatibility with snapshots older than CBT implementation
186
+ // update vdi data to ensure the vdi are correctly fetched in _jobSnapshotVdis
187
+ // remove by then end of 2024
188
+ const vmSnapshotsRef = await xapi.getField('VM', vmRef, 'snapshots')
189
+ const vmSnapshotsOtherConfig = await asyncMap(vmSnapshotsRef, ref => xapi.getField('VM', ref, 'other_config'))
181
190
 
182
- const snapshots = []
183
- snapshotsOtherConfig.forEach((other_config, i) => {
184
- if (other_config['xo:backup:job'] === jobId) {
185
- snapshots.push({ other_config, $ref: snapshotsRef[i] })
191
+ const vmSnapshots = []
192
+ vmSnapshotsOtherConfig.forEach((other_config, i) => {
193
+ if (other_config[JOB_ID] === jobId) {
194
+ vmSnapshots.push({ other_config, $ref: vmSnapshotsRef[i] })
186
195
  }
187
196
  })
188
- snapshots.sort((a, b) => (a.other_config['xo:backup:datetime'] < b.other_config['xo:backup:datetime'] ? -1 : 1))
189
- this._jobSnapshots = snapshots
197
+ await Promise.all(vmSnapshots.map(snapshot => populateVdisOtherConfig(xapi, snapshot.$ref)))
198
+ // end of compatibiliy handling
199
+
200
+ // handle snapshot by VDI
201
+ this._jobSnapshotVdis = []
202
+ const srcVdis = await xapi.getRecords('VDI', await this._vm.$getDisks())
203
+ for (const srcVdi of srcVdis) {
204
+ const snapshots = await xapi.getRecords('VDI', srcVdi.snapshots)
205
+ for (const snapshot of snapshots) {
206
+ if (snapshot.other_config[JOB_ID] === jobId) {
207
+ this._jobSnapshotVdis.push(snapshot)
208
+ }
209
+ }
210
+ }
190
211
  }
191
212
 
192
213
  async _removeUnusedSnapshots() {
193
214
  const allSettings = this.job.settings
194
215
  const baseSettings = this._baseSettings
195
- const baseVmRef = this._baseVm?.$ref
196
216
 
197
- const snapshotsPerSchedule = groupBy(this._jobSnapshots, _ => _.other_config['xo:backup:schedule'])
217
+ const snapshotsPerSchedule = groupBy(this._jobSnapshotVdis, _ => _.other_config[SCHEDULE_ID])
198
218
  const xapi = this._xapi
199
- await asyncMap(Object.entries(snapshotsPerSchedule), ([scheduleId, snapshots]) => {
219
+ await asyncMap(Object.entries(snapshotsPerSchedule), async ([scheduleId, snapshots]) => {
220
+ const snapshotPerDatetime = groupBy(snapshots, _ => _.other_config[DATETIME])
221
+
222
+ const datetimes = Object.keys(snapshotPerDatetime)
223
+ datetimes.sort()
224
+
200
225
  const settings = {
201
226
  ...baseSettings,
202
227
  ...allSettings[scheduleId],
203
228
  ...allSettings[this._vm.uuid],
204
229
  }
205
- return asyncMap(getOldEntries(settings.snapshotRetention, snapshots), ({ $ref }) => {
206
- if ($ref !== baseVmRef) {
207
- return xapi.VM_destroy($ref)
230
+ // ensure we never delete the last one
231
+ const retention = Math.max(settings.snapshotRetention ?? 0, 1)
232
+
233
+ await asyncMap(getOldEntries(retention, datetimes), async datetime => {
234
+ const vdis = snapshotPerDatetime[datetime]
235
+
236
+ let vmRef
237
+ // if there is an attached VM => destroy the VM (Non CBT backups)
238
+ for (const vdi of vdis) {
239
+ if (vdi.$VBDs.length > 0) {
240
+ const vbds = vdi.$VBDs
241
+ // only one VM linked to this vdi
242
+ // this will throw error for VDI still attached to control domain
243
+ assert.strictEqual(vbds.length, 1, 'VDI must be free or attached to exactly one VM')
244
+ const vm = vbds[0].$VM
245
+ assert.strictEqual(vm.is_control_domain, false, `Disk is still attached to DOM0 VM`) // don't delete a VM (especially a control domain)
246
+ assert.strictEqual(vm.is_a_snapshot, true, `VM must be a snapshot`) // don't delete a VM (especially a control domain)
247
+
248
+ const vmRefVdi = vm.$ref
249
+ // same vm than other vdi of the same batch
250
+ assert.ok(
251
+ vmRef === undefined || vmRef === vmRefVdi,
252
+ '_removeUnusedSnapshots don t handle vdi related to multiple VMs '
253
+ )
254
+ vmRef = vmRefVdi
255
+ }
256
+ }
257
+ if (vmRef !== undefined) {
258
+ return xapi.VM_destroy(vmRef)
259
+ } else {
260
+ return asyncMap(
261
+ vdis.map(async ({ $ref }) => {
262
+ await xapi.VDI_destroy($ref)
263
+ })
264
+ )
208
265
  }
209
266
  })
210
267
  })
268
+
269
+ // now that we use CBT, we can destroy the data of the snapshot used for this backup
270
+ // going back to a previous version of XO not supporting CBT will create a full backup
271
+ // this will only do something after snapshot and transfer
272
+ if (
273
+ // don't modify the VM
274
+ this._exportedVm?.is_a_snapshot &&
275
+ // user don't want to keep the snapshot data
276
+ this._settings.snapshotRetention === 0 &&
277
+ // preferNbd is not a guarantee that the backup used NBD, depending on the network configuration
278
+ this._settings.preferNbd &&
279
+ // only delete snapshost data if the config allows it
280
+ this.config.purgeSnapshotData
281
+ ) {
282
+ Task.info('will delete snapshot data')
283
+ const vdiRefs = await this._xapi.VM_getDisks(this._exportedVm?.$ref)
284
+ await xapi.call('VM.destroy', this._exportedVm.$ref)
285
+ for (const vdiRef of vdiRefs) {
286
+ try {
287
+ // data_destroy will fail with a VDI_NO_CBT_METADATA error if CBT is not enabled on this VDI
288
+ await xapi.VDI_dataDestroy(vdiRef)
289
+ Task.info(`Snapshot data has been deleted`, { vdiRef })
290
+ } catch (error) {
291
+ Task.warning(`Couldn't deleted snapshot data`, { error, vdiRef })
292
+ }
293
+ }
294
+ }
211
295
  }
212
296
 
213
297
  async copy() {
@@ -1,6 +1,5 @@
1
1
  import ignoreErrors from 'promise-toolbox/ignoreErrors'
2
2
  import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
3
- import { formatDateTime } from '@xen-orchestra/xapi'
4
3
 
5
4
  import { formatFilenameDate } from '../../_filenameDate.mjs'
6
5
  import { getOldEntries } from '../../_getOldEntries.mjs'
@@ -9,6 +8,7 @@ import { Task } from '../../Task.mjs'
9
8
  import { AbstractFullWriter } from './_AbstractFullWriter.mjs'
10
9
  import { MixinXapiWriter } from './_MixinXapiWriter.mjs'
11
10
  import { listReplicatedVms } from './_listReplicatedVms.mjs'
11
+ import { setVmOtherConfig } from '../../_otherConfig.mjs'
12
12
 
13
13
  export class FullXapiWriter extends MixinXapiWriter(AbstractFullWriter) {
14
14
  constructor(props) {
@@ -76,14 +76,12 @@ export class FullXapiWriter extends MixinXapiWriter(AbstractFullWriter) {
76
76
  'Start operation for this vm is blocked, clone it if you want to use it.'
77
77
  )
78
78
  ),
79
- targetVm.update_other_config({
80
- 'xo:backup:sr': srUuid,
81
-
82
- // these entries need to be added in case of offline backup
83
- 'xo:backup:datetime': formatDateTime(timestamp),
84
- 'xo:backup:job': job.id,
85
- 'xo:backup:schedule': scheduleId,
86
- 'xo:backup:vm': vm.uuid,
79
+ setVmOtherConfig(xapi, targetVmRef, {
80
+ timestamp,
81
+ jobId: job.id,
82
+ scheduleId,
83
+ srUuid,
84
+ vmUuid: vm.uuid,
87
85
  }),
88
86
  ])
89
87
 
@@ -1,39 +1,34 @@
1
- import assert from 'node:assert'
2
1
  import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
3
2
  import ignoreErrors from 'promise-toolbox/ignoreErrors'
4
- import { formatDateTime } from '@xen-orchestra/xapi'
5
3
 
6
4
  import { formatFilenameDate } from '../../_filenameDate.mjs'
7
5
  import { getOldEntries } from '../../_getOldEntries.mjs'
8
- import { importIncrementalVm, TAG_BACKUP_SR, TAG_BASE_DELTA, TAG_COPY_SRC } from '../../_incrementalVm.mjs'
6
+ import { importIncrementalVm } from '../../_incrementalVm.mjs'
9
7
  import { Task } from '../../Task.mjs'
10
8
 
11
9
  import { AbstractIncrementalWriter } from './_AbstractIncrementalWriter.mjs'
12
10
  import { MixinXapiWriter } from './_MixinXapiWriter.mjs'
13
11
  import { listReplicatedVms } from './_listReplicatedVms.mjs'
14
- import find from 'lodash/find.js'
12
+ import { COPY_OF, setVmOtherConfig, BASE_DELTA_VDI } from '../../_otherConfig.mjs'
15
13
 
14
+ import assert from 'node:assert'
16
15
  export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
17
- async checkBaseVdis(baseUuidToSrcVdi, baseVm) {
18
- assert.notStrictEqual(baseVm, undefined)
16
+ async checkBaseVdis(baseUuidToSrcVdi) {
19
17
  const sr = this._sr
20
- const replicatedVm = listReplicatedVms(sr.$xapi, this._job.id, sr.uuid, this._vmUuid).find(
21
- vm => vm.other_config[TAG_COPY_SRC] === baseVm.uuid
22
- )
23
- if (replicatedVm === undefined) {
24
- return baseUuidToSrcVdi.clear()
25
- }
26
18
 
27
- const xapi = replicatedVm.$xapi
28
- const replicatedVdis = new Set(
29
- await asyncMap(await replicatedVm.$getDisks(), async vdiRef => {
30
- const otherConfig = await xapi.getField('VDI', vdiRef, 'other_config')
31
- return otherConfig[TAG_COPY_SRC]
19
+ // @todo use an index if possible
20
+ // @todo : this seems similare to decorateVmMetadata
21
+
22
+ const replicatedVdis = sr.$VDIs
23
+ .filter(({ other_config }) => {
24
+ // REPLICATED_TO_SR_UUID is not used here since we are already filtering from sr.$VDIs
25
+ return baseUuidToSrcVdi.has(other_config?.[COPY_OF])
32
26
  })
33
- )
27
+ .map(({ other_config }) => other_config?.[COPY_OF])
28
+ .filter(_ => !!_)
34
29
 
35
30
  for (const uuid of baseUuidToSrcVdi.keys()) {
36
- if (!replicatedVdis.has(uuid)) {
31
+ if (!replicatedVdis.includes(uuid)) {
37
32
  baseUuidToSrcVdi.delete(uuid)
38
33
  }
39
34
  }
@@ -89,46 +84,38 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
89
84
  #decorateVmMetadata(backup) {
90
85
  const { _warmMigration } = this._settings
91
86
  const sr = this._sr
92
- const xapi = sr.$xapi
93
87
  const vm = backup.vm
94
- vm.other_config[TAG_COPY_SRC] = vm.uuid
95
- const remoteBaseVmUuid = vm.other_config[TAG_BASE_DELTA]
96
- let baseVm
97
- if (remoteBaseVmUuid) {
98
- baseVm = find(
99
- xapi.objects.all,
100
- obj => (obj = obj.other_config) && obj[TAG_COPY_SRC] === remoteBaseVmUuid && obj[TAG_BACKUP_SR] === sr.$id
101
- )
102
-
103
- if (!baseVm) {
104
- throw new Error(`could not find the base VM (copy of ${remoteBaseVmUuid})`)
105
- }
106
- }
107
- const baseVdis = {}
108
- baseVm?.$VBDs.forEach(vbd => {
109
- const vdi = vbd.$VDI
110
- if (vdi !== undefined) {
111
- baseVdis[vbd.VDI] = vbd.$VDI
112
- }
113
- })
114
88
 
115
- vm.other_config[TAG_COPY_SRC] = vm.uuid
89
+ vm.other_config[COPY_OF] = vm.uuid
116
90
  if (!_warmMigration) {
117
91
  vm.tags.push('Continuous Replication')
118
92
  }
93
+ // extracting the uuid of each delta vdi on the source
94
+ // get all in one pass, since there is a lot of objects
95
+ const sourceVdiUuids = Object.values(backup.vdis)
96
+ .map(({ other_config }) => other_config[BASE_DELTA_VDI])
97
+ // full vdi don't have a base
98
+ .filter(_ => !!_)
99
+ // @todo use index ?
100
+
101
+ const replicatedVdis = sr.$VDIs.filter(({ other_config }) => {
102
+ // REPLICATED_TO_SR_UUID is not used here since we are already filtering from sr.$VDIs
103
+ return sourceVdiUuids.includes(other_config?.[COPY_OF])
104
+ })
119
105
 
120
106
  Object.values(backup.vdis).forEach(vdi => {
121
- vdi.other_config[TAG_COPY_SRC] = vdi.uuid
122
- vdi.SR = sr.$ref
123
- // vdi.other_config[TAG_BASE_DELTA] is never defined on a suspend vdi
124
- if (vdi.other_config[TAG_BASE_DELTA]) {
125
- const remoteBaseVdiUuid = vdi.other_config[TAG_BASE_DELTA]
126
- const baseVdi = find(baseVdis, vdi => vdi.other_config[TAG_COPY_SRC] === remoteBaseVdiUuid)
127
- if (!baseVdi) {
128
- throw new Error(`missing base VDI (copy of ${remoteBaseVdiUuid})`)
129
- }
130
- vdi.baseVdi = baseVdi
107
+ vdi.other_config[COPY_OF] = vdi.uuid
108
+ if (sourceVdiUuids.length > 0) {
109
+ const baseReplicatedTo = replicatedVdis.find(
110
+ replicatedVdi => replicatedVdi.other_config[COPY_OF] === vdi.other_config[BASE_DELTA_VDI]
111
+ )
112
+ assert.notStrictEqual(baseReplicatedTo, undefined)
113
+ vdi.baseVdi = baseReplicatedTo
114
+ } else {
115
+ vdi.baseVdi = undefined
131
116
  }
117
+ // ensure the VDI are created on the target SR
118
+ vdi.SR = sr.$ref
132
119
  })
133
120
 
134
121
  return backup
@@ -164,14 +151,12 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
164
151
  'Start operation for this vm is blocked, clone it if you want to use it.'
165
152
  )
166
153
  ),
167
- targetVm.update_other_config({
168
- [TAG_BACKUP_SR]: srUuid,
169
-
170
- // these entries need to be added in case of offline backup
171
- 'xo:backup:datetime': formatDateTime(timestamp),
172
- 'xo:backup:job': job.id,
173
- 'xo:backup:schedule': scheduleId,
174
- [TAG_BASE_DELTA]: vm.uuid,
154
+ setVmOtherConfig(xapi, targetVmRef, {
155
+ timestamp,
156
+ jobId: job.id,
157
+ scheduleId,
158
+ vmUuid: vm.uuid,
159
+ srUuid,
175
160
  }),
176
161
  ])
177
162
  }
@@ -38,6 +38,7 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
38
38
  },
39
39
  lock: false,
40
40
  mergeBlockConcurrency: this._config.mergeBlockConcurrency,
41
+ removeTmp: true,
41
42
  })
42
43
  })
43
44
  } catch (error) {
@@ -1,5 +1,7 @@
1
+ import { DATETIME, JOB_ID, REPLICATED_TO_SR_UUID, SCHEDULE_ID, VM_UUID } from '../../_otherConfig.mjs'
2
+
1
3
  const getReplicatedVmDatetime = vm => {
2
- const { 'xo:backup:datetime': datetime = vm.name_label.slice(-17, -1) } = vm.other_config
4
+ const { [DATETIME]: datetime = vm.name_label.slice(-17, -1) } = vm.other_config
3
5
  return datetime
4
6
  }
5
7
 
@@ -16,11 +18,11 @@ export function listReplicatedVms(xapi, scheduleOrJobId, srUuid, vmUuid) {
16
18
  !object.is_a_snapshot &&
17
19
  !object.is_a_template &&
18
20
  'start' in object.blocked_operations &&
19
- (oc['xo:backup:job'] === scheduleOrJobId || oc['xo:backup:schedule'] === scheduleOrJobId) &&
20
- oc['xo:backup:sr'] === srUuid &&
21
- (oc['xo:backup:vm'] === vmUuid ||
21
+ (oc[JOB_ID] === scheduleOrJobId || oc[SCHEDULE_ID] === scheduleOrJobId) &&
22
+ oc[REPLICATED_TO_SR_UUID] === srUuid &&
23
+ (oc[VM_UUID] === vmUuid ||
22
24
  // 2018-03-28, JFT: to catch VMs replicated before this fix
23
- oc['xo:backup:vm'] === undefined)
25
+ oc[VM_UUID] === undefined)
24
26
  ) {
25
27
  vms[object.$id] = object
26
28
  }
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.49.0",
11
+ "version": "0.50.0",
12
12
  "engines": {
13
13
  "node": ">=14.18"
14
14
  },
@@ -24,7 +24,7 @@
24
24
  "@vates/compose": "^2.1.0",
25
25
  "@vates/decorate-with": "^2.1.0",
26
26
  "@vates/disposable": "^0.1.5",
27
- "@vates/fuse-vhd": "^2.1.0",
27
+ "@vates/fuse-vhd": "^2.1.1",
28
28
  "@vates/nbd-client": "^3.0.2",
29
29
  "@vates/parse-duration": "^0.1.1",
30
30
  "@xen-orchestra/async-map": "^0.1.2",
@@ -46,7 +46,7 @@
46
46
  "proper-lockfile": "^4.1.2",
47
47
  "tar": "^6.1.15",
48
48
  "uuid": "^9.0.0",
49
- "vhd-lib": "^4.9.2",
49
+ "vhd-lib": "^4.10.0",
50
50
  "xen-api": "^4.0.0",
51
51
  "yazl": "^2.5.1"
52
52
  },
@@ -58,7 +58,7 @@
58
58
  "tmp": "^0.2.1"
59
59
  },
60
60
  "peerDependencies": {
61
- "@xen-orchestra/xapi": "^6.0.0"
61
+ "@xen-orchestra/xapi": "^6.1.0"
62
62
  },
63
63
  "license": "AGPL-3.0-or-later",
64
64
  "author": {