@xen-orchestra/backups 0.49.1 → 0.51.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
+ }
@@ -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
 
@@ -148,19 +148,17 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
148
148
  }
149
149
 
150
150
  const snapshotRef = await vm[settings.checkpointSnapshot ? '$checkpoint' : '$snapshot']({
151
- ignoreNobakVdis: true,
151
+ ignoredVdisTag: '[NOBAK]',
152
152
  name_label: this._getSnapshotNameLabel(vm),
153
153
  unplugVusbs: true,
154
154
  })
155
155
  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,
156
+ await setVmOtherConfig(xapi, snapshotRef, {
157
+ timestamp: this.timestamp,
158
+ jobId: this._jobId,
159
+ scheduleId: this.scheduleId,
160
+ vmUuid: vm.uuid,
162
161
  })
163
-
164
162
  this._exportedVm = await xapi.getRecord('VM', snapshotRef)
165
163
 
166
164
  return this._exportedVm.uuid
@@ -176,35 +174,86 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
176
174
  const vmRef = this._vm.$ref
177
175
  const xapi = this._xapi
178
176
 
179
- const snapshotsRef = await xapi.getField('VM', vmRef, 'snapshots')
180
- const snapshotsOtherConfig = await asyncMap(snapshotsRef, ref => xapi.getField('VM', ref, 'other_config'))
177
+ // to ensure compatibility with snapshots older than CBT implementation
178
+ // update vdi data to ensure the vdi are correctly fetched in _jobSnapshotVdis
179
+ // remove by then end of 2024
180
+ const vmSnapshotsRef = await xapi.getField('VM', vmRef, 'snapshots')
181
+ const vmSnapshotsOtherConfig = await asyncMap(vmSnapshotsRef, ref => xapi.getField('VM', ref, 'other_config'))
181
182
 
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] })
183
+ const vmSnapshots = []
184
+ vmSnapshotsOtherConfig.forEach((other_config, i) => {
185
+ if (other_config[JOB_ID] === jobId) {
186
+ vmSnapshots.push({ other_config, $ref: vmSnapshotsRef[i] })
186
187
  }
187
188
  })
188
- snapshots.sort((a, b) => (a.other_config['xo:backup:datetime'] < b.other_config['xo:backup:datetime'] ? -1 : 1))
189
- this._jobSnapshots = snapshots
189
+ await Promise.all(vmSnapshots.map(snapshot => populateVdisOtherConfig(xapi, snapshot.$ref)))
190
+ // end of compatibiliy handling
191
+
192
+ // handle snapshot by VDI
193
+ this._jobSnapshotVdis = []
194
+ const srcVdis = await xapi.getRecords('VDI', await this._vm.$getDisks())
195
+ for (const srcVdi of srcVdis) {
196
+ const snapshots = await xapi.getRecords('VDI', srcVdi.snapshots)
197
+ for (const snapshot of snapshots) {
198
+ if (snapshot.other_config[JOB_ID] === jobId) {
199
+ this._jobSnapshotVdis.push(snapshot)
200
+ }
201
+ }
202
+ }
190
203
  }
191
204
 
192
205
  async _removeUnusedSnapshots() {
193
206
  const allSettings = this.job.settings
194
207
  const baseSettings = this._baseSettings
195
- const baseVmRef = this._baseVm?.$ref
196
208
 
197
- const snapshotsPerSchedule = groupBy(this._jobSnapshots, _ => _.other_config['xo:backup:schedule'])
209
+ const snapshotsPerSchedule = groupBy(this._jobSnapshotVdis, _ => _.other_config[SCHEDULE_ID])
198
210
  const xapi = this._xapi
199
211
  await asyncMap(Object.entries(snapshotsPerSchedule), ([scheduleId, snapshots]) => {
212
+ const snapshotPerDatetime = groupBy(snapshots, _ => _.other_config[DATETIME])
213
+
214
+ const datetimes = Object.keys(snapshotPerDatetime)
215
+ datetimes.sort()
216
+
200
217
  const settings = {
201
218
  ...baseSettings,
202
219
  ...allSettings[scheduleId],
203
220
  ...allSettings[this._vm.uuid],
204
221
  }
205
- return asyncMap(getOldEntries(settings.snapshotRetention, snapshots), ({ $ref }) => {
206
- if ($ref !== baseVmRef) {
207
- return xapi.VM_destroy($ref)
222
+ // ensure we never delete the last one
223
+ const retention = Math.max(settings.snapshotRetention ?? 0, 1)
224
+
225
+ return asyncMap(getOldEntries(retention, datetimes), async datetime => {
226
+ const vdis = snapshotPerDatetime[datetime]
227
+
228
+ let vmRef
229
+ // if there is an attached VM => destroy the VM (Non CBT backups)
230
+ for (const vdi of vdis) {
231
+ if (vdi.$VBDs.length > 0) {
232
+ const vbds = vdi.$VBDs
233
+ // only one VM linked to this vdi
234
+ // this will throw error for VDI still attached to control domain
235
+ assert.strictEqual(vbds.length, 1, 'VDI must be free or attached to exactly one VM')
236
+ const vm = vbds[0].$VM
237
+ assert.strictEqual(vm.is_a_snapshot, true, `VM must be a snapshot`) // don't delete a VM (especially a control domain)
238
+ assert.strictEqual(vm.is_control_domain, false, `VM can't be a DOM0 VM`) // don't delete a VM (especially a control domain)
239
+
240
+ const vmRefVdi = vm.$ref
241
+ // same vm than other vdi of the same batch
242
+ assert.ok(
243
+ vmRef === undefined || vmRef === vmRefVdi,
244
+ '_removeUnusedSnapshots don t handle vdi related to multiple VMs '
245
+ )
246
+ vmRef = vmRefVdi
247
+ }
248
+ }
249
+ if (vmRef !== undefined) {
250
+ return xapi.VM_destroy(vmRef)
251
+ } else {
252
+ return asyncMap(
253
+ vdis.map(async ({ $ref }) => {
254
+ await xapi.VDI_destroy($ref)
255
+ })
256
+ )
208
257
  }
209
258
  })
210
259
  })
@@ -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
  }
@@ -57,10 +52,10 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
57
52
  this.cleanup = task.wrapFn(this.cleanup, !hasHealthCheckSr)
58
53
  this.healthCheck = task.wrapFn(this.healthCheck, hasHealthCheckSr)
59
54
 
60
- return task.run(() => this._prepare())
55
+ return task.run(() => this._prepare(isFull))
61
56
  }
62
57
 
63
- async _prepare() {
58
+ async _prepare(isFull) {
64
59
  const settings = this._settings
65
60
  const { uuid: srUuid, $xapi: xapi } = this._sr
66
61
  const vmUuid = this._vmUuid
@@ -72,14 +67,19 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
72
67
  this._oldEntries = getOldEntries(settings.copyRetention - 1, listReplicatedVms(xapi, scheduleId, srUuid, vmUuid))
73
68
 
74
69
  if (settings.deleteFirst) {
70
+ // we want to keep the baseVM when copying a delta
71
+ // even if we want to keep only one after
72
+ let mostRecentEntry
73
+ if (this._oldEntries.length > 1 && settings.copyRetention === 1 && !isFull) {
74
+ mostRecentEntry = this._oldEntries.pop()
75
+ }
75
76
  await this._deleteOldEntries()
77
+ this._oldEntries = mostRecentEntry !== undefined ? [mostRecentEntry] : []
76
78
  }
77
79
  }
78
80
 
79
81
  async cleanup() {
80
- if (!this._settings.deleteFirst) {
81
- await this._deleteOldEntries()
82
- }
82
+ await this._deleteOldEntries()
83
83
  }
84
84
 
85
85
  async _deleteOldEntries() {
@@ -89,46 +89,38 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
89
89
  #decorateVmMetadata(backup) {
90
90
  const { _warmMigration } = this._settings
91
91
  const sr = this._sr
92
- const xapi = sr.$xapi
93
92
  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
93
 
115
- vm.other_config[TAG_COPY_SRC] = vm.uuid
94
+ vm.other_config[COPY_OF] = vm.uuid
116
95
  if (!_warmMigration) {
117
96
  vm.tags.push('Continuous Replication')
118
97
  }
98
+ // extracting the uuid of each delta vdi on the source
99
+ // get all in one pass, since there is a lot of objects
100
+ const sourceVdiUuids = Object.values(backup.vdis)
101
+ .map(({ other_config }) => other_config[BASE_DELTA_VDI])
102
+ // full vdi don't have a base
103
+ .filter(_ => !!_)
104
+ // @todo use index ?
105
+
106
+ const replicatedVdis = sr.$VDIs.filter(({ other_config }) => {
107
+ // REPLICATED_TO_SR_UUID is not used here since we are already filtering from sr.$VDIs
108
+ return sourceVdiUuids.includes(other_config?.[COPY_OF])
109
+ })
119
110
 
120
111
  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
112
+ vdi.other_config[COPY_OF] = vdi.uuid
113
+ if (sourceVdiUuids.length > 0) {
114
+ const baseReplicatedTo = replicatedVdis.find(
115
+ replicatedVdi => replicatedVdi.other_config[COPY_OF] === vdi.other_config[BASE_DELTA_VDI]
116
+ )
117
+ assert.notStrictEqual(baseReplicatedTo, undefined)
118
+ vdi.baseVdi = baseReplicatedTo
119
+ } else {
120
+ vdi.baseVdi = undefined
131
121
  }
122
+ // ensure the VDI are created on the target SR
123
+ vdi.SR = sr.$ref
132
124
  })
133
125
 
134
126
  return backup
@@ -164,14 +156,12 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
164
156
  'Start operation for this vm is blocked, clone it if you want to use it.'
165
157
  )
166
158
  ),
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,
159
+ setVmOtherConfig(xapi, targetVmRef, {
160
+ timestamp,
161
+ jobId: job.id,
162
+ scheduleId,
163
+ vmUuid: vm.uuid,
164
+ srUuid,
175
165
  }),
176
166
  ])
177
167
  }
@@ -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.1",
11
+ "version": "0.51.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": "^7.0.0"
62
62
  },
63
63
  "license": "AGPL-3.0-or-later",
64
64
  "author": {