@xen-orchestra/backups 0.70.0 → 0.71.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/_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
+ [COPY_OF]: null,
88
89
  [DATETIME]: null,
89
90
  [DELTA_CHAIN_LENGTH]: null,
90
91
  [EXPORTED_SUCCESSFULLY]: null,
@@ -105,7 +105,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
105
105
  config,
106
106
  healthCheckSr,
107
107
  job,
108
- scheduleId: schedule.id,
108
+ schedule,
109
109
  vmUuid: vm.uuid,
110
110
  settings,
111
111
  })
@@ -123,7 +123,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
123
123
  config,
124
124
  healthCheckSr,
125
125
  job,
126
- scheduleId: schedule.id,
126
+ schedule,
127
127
  vmUuid: vm.uuid,
128
128
  remoteId,
129
129
  settings: targetSettings,
@@ -141,7 +141,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
141
141
  healthCheckSr,
142
142
  job,
143
143
  ReplicationWriter,
144
- scheduleId: schedule.id,
144
+ schedule,
145
145
  vmUuid: vm.uuid,
146
146
  srs,
147
147
  settings,
@@ -159,7 +159,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
159
159
  config,
160
160
  healthCheckSr,
161
161
  job,
162
- scheduleId: schedule.id,
162
+ schedule,
163
163
  vmUuid: vm.uuid,
164
164
  sr,
165
165
  settings: targetSettings,
@@ -174,11 +174,9 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
174
174
 
175
175
  // ensure the VM itself does not have any backup metadata which would be
176
176
  // copied on manual snapshots and interfere with the backup jobs
177
+
177
178
  async _cleanMetadata() {
178
- const vm = this._vm
179
- if (JOB_ID in vm.other_config) {
180
- await resetVmOtherConfig(this._xapi, vm.$ref)
181
- }
179
+ await resetVmOtherConfig(this._xapi, this._vm.$ref)
182
180
  }
183
181
 
184
182
  async _snapshot() {
@@ -27,7 +27,7 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
27
27
  async _run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
28
28
  const settings = this._settings
29
29
  const job = this._job
30
- const scheduleId = this._scheduleId
30
+ const scheduleId = this._schedule.id
31
31
 
32
32
  const adapter = this._adapter
33
33
  let metadata = await this._isAlreadyTransferred(timestamp)
@@ -34,7 +34,7 @@ export class FullXapiWriter extends MixinXapiWriter(AbstractFullWriter) {
34
34
  const sr = this._sr
35
35
  const settings = this._settings
36
36
  const job = this._job
37
- const scheduleId = this._scheduleId
37
+ const scheduleId = this._schedule.id
38
38
 
39
39
  const { uuid: srUuid, $xapi: xapi } = sr
40
40
 
@@ -90,7 +90,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
90
90
  async _prepare() {
91
91
  const adapter = this._adapter
92
92
  const settings = this._settings
93
- const scheduleId = this._scheduleId
93
+ const scheduleId = this._schedule.id
94
94
  const vmUuid = this._vmUuid
95
95
 
96
96
  const oldEntries = getOldEntries(
@@ -180,7 +180,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
180
180
  async _transfer($defer, { isVhdDifferencing, timestamp, deltaExport, vm, vmSnapshot }) {
181
181
  const adapter = this._adapter
182
182
  const job = this._job
183
- const scheduleId = this._scheduleId
183
+ const scheduleId = this._schedule.id
184
184
  const settings = this._settings
185
185
  const jobId = job.id
186
186
  const handler = adapter.handler
@@ -1,3 +1,5 @@
1
+ import humanFormat from 'human-format'
2
+
1
3
  import { asyncMapSettled } from '@xen-orchestra/async-map'
2
4
  import ignoreErrors from 'promise-toolbox/ignoreErrors'
3
5
 
@@ -18,50 +20,105 @@ import {
18
20
  DATETIME,
19
21
  VM_UUID,
20
22
  } from '../../_otherConfig.mjs'
21
- import assert from 'node:assert'
22
23
  import { formatFilenameDate } from '../../_filenameDate.mjs'
24
+ import { XapiDiskSource } from '@xen-orchestra/xapi'
25
+ import { asyncEach } from '@vates/async-each'
26
+ import { createLogger } from '@xen-orchestra/log'
27
+
28
+ const { debug } = createLogger('xo:backups:IncrementalXapiWriter')
23
29
 
24
30
  export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
31
+ // Map of source VDI UUID (COPY_OF) → validated active VDI on the target SR.
32
+ // Built by checkBaseVdis, consumed by #decorateVmMetadata to set baseVdi.
33
+ #baseVdisBySourceUuid = new Map()
34
+
25
35
  async checkBaseVdis(baseUuidToSrcVdi) {
26
36
  const sr = this._sr
37
+ this.#baseVdisBySourceUuid = new Map()
38
+
27
39
  if (baseUuidToSrcVdi.size === 0) {
28
40
  // searching for the vdis is expensive
29
41
  // don't do it if there is nothing to find
30
42
  return
31
43
  }
32
44
 
33
- // Only match live (non-snapshot) VDIs attached to exactly one
34
- // non-control-domain VM that is a valid replication target for this job.
35
- // This must stay consistent with the filter in #decorateVmMetadata.
36
- const replicatedVdis = sr.$VDIs.filter(vdi => {
37
- if (!vdi?.managed || vdi?.is_a_snapshot || !baseUuidToSrcVdi.has(vdi?.other_config[COPY_OF])) {
38
- return false
39
- }
40
- const userVbds = vdi.$VBDs?.filter(vbd => vbd.$VM && !vbd.$VM.is_control_domain) ?? []
41
- if (userVbds.length !== 1) {
42
- return false
43
- }
44
- const vm = userVbds[0].$VM
45
+ // look for the same snapshot
46
+ // ensure there are no data between the snapshot and the active disk
47
+
48
+ const snapshotCandidates = sr.$VDIs.filter(vdi => {
45
49
  return (
46
- vm.other_config[JOB_ID] === this._job.id &&
47
- vm.other_config[VM_UUID] === this._vmUuid &&
48
- 'start' in vm.blocked_operations
50
+ vdi?.managed &&
51
+ vdi?.is_a_snapshot &&
52
+ vdi.other_config[JOB_ID] === this._job.id &&
53
+ vdi.other_config[VM_UUID] === this._vmUuid &&
54
+ baseUuidToSrcVdi.has(vdi?.other_config[COPY_OF])
49
55
  )
50
56
  })
57
+ debug('checkBaseVdis, got snapshot candidates,', snapshotCandidates.length)
51
58
 
52
- const replicatedCopyOfUuids = replicatedVdis.map(({ other_config }) => other_config?.[COPY_OF]).filter(_ => !!_)
59
+ // ensure no data have been written since this snapshot
60
+ // but there may be have some other snapshot for another job
61
+ let targetVmRef
62
+ let canChainToTargetVm = true
63
+ await asyncEach(
64
+ snapshotCandidates,
65
+ async snapshot => {
66
+ let diffDisk
67
+ try {
68
+ const activeVdi = sr.$xapi.getObject(snapshot.$snapshot_of)
69
+ const userVbds = activeVdi.$VBDs?.filter(vbd => vbd.$VM && !vbd.$VM.is_control_domain) ?? []
70
+ if (userVbds.length !== 1) {
71
+ debug('checkBaseVdis, share vbd ', { ref: snapshot.$ref, userVbds })
72
+ // shared vdi ignore
73
+ return
74
+ }
75
+ const vm = userVbds[0].$VM
76
+ if (!('start' in vm.blocked_operations)) {
77
+ debug('checkBaseVdis, vm not blocked', { vmRef: vm.$ref })
78
+ // vm start unlocked
79
+ // not really an issue since we have check the delta
80
+ // but it indicates the users played with the blocked operations
81
+ return
82
+ }
83
+ diffDisk = new XapiDiskSource({ xapi: sr.$xapi, vdiRef: activeVdi.$ref, baseRef: snapshot.$ref })
84
+ await diffDisk.init()
85
+ if (diffDisk.getBlockIndexes().length === 0) {
86
+ const sourceUuid = snapshot.other_config?.[COPY_OF]
87
+ if (sourceUuid) {
88
+ this.#baseVdisBySourceUuid.set(sourceUuid, activeVdi)
89
+ }
90
+ // Track the target VM (the replicated VM to update on the next transfer).
91
+ targetVmRef = vm.$ref
92
+ } else {
93
+ // not empty, we will create a new VM
94
+ canChainToTargetVm = false
95
+ debug('checkBaseVdis, data between snapshot and active disk', {
96
+ vdiRef: snapshot.$ref,
97
+ nbBlocks: diffDisk.getBlockIndexes().length,
98
+ })
99
+ }
100
+ } catch (error) {
101
+ debug('checkBaseVdis, skipping snapshot', { ref: snapshot.$ref, error })
102
+ return
103
+ } finally {
104
+ await diffDisk?.close().catch(error => debug('checkBaseVdis, error closing', error))
105
+ }
106
+ },
107
+ {
108
+ concurrency: 4,
109
+ }
110
+ )
111
+
112
+ if (canChainToTargetVm && targetVmRef !== undefined) {
113
+ debug('checkBaseVdis,got a valid vm target', targetVmRef)
114
+ this._targetVmRef = targetVmRef
115
+ }
53
116
 
54
117
  for (const uuid of baseUuidToSrcVdi.keys()) {
55
- if (!replicatedCopyOfUuids.includes(uuid)) {
118
+ if (!this.#baseVdisBySourceUuid.has(uuid)) {
56
119
  baseUuidToSrcVdi.delete(uuid)
57
120
  }
58
121
  }
59
-
60
- // Track the target VM (the replicated VM to update on the next transfer).
61
- if (replicatedVdis.length > 0) {
62
- const vbd = replicatedVdis[0].$VBDs.find(vbd => vbd.$VM && !vbd.$VM.is_control_domain)
63
- this._targetVmRef = vbd.$VM.$ref
64
- }
65
122
  }
66
123
  updateUuidAndChain() {
67
124
  // nothing to do, the chaining is not modified in this case
@@ -89,7 +146,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
89
146
  const settings = this._settings
90
147
  const { uuid: srUuid, $xapi: xapi } = this._sr
91
148
  const vmUuid = this._vmUuid
92
- const scheduleId = this._scheduleId
149
+ const scheduleId = this._schedule.id
93
150
 
94
151
  // delete previous interrupted copies
95
152
  ignoreErrors.call(asyncMapSettled(listReplicatedVms(xapi, scheduleId, undefined, vmUuid), vm => vm.$destroy))
@@ -132,9 +189,8 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
132
189
  const sr = this._sr
133
190
  const vm = backup.vm
134
191
  const job = this._job
135
- const scheduleId = this._scheduleId
192
+ const scheduleId = this._schedule.id
136
193
 
137
- vm.name_label = `${vm.name_label} - ${job.name}`
138
194
  // update other_config data as soon as possible to ensure the next job
139
195
  // will be able to detect any partial transfer and lean them
140
196
  vm.other_config[COPY_OF] = vm.uuid
@@ -152,18 +208,6 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
152
208
  if (!_warmMigration) {
153
209
  vm.tags.push('Continuous Replication')
154
210
  }
155
- // extracting the uuid of each delta vdi on the source
156
- // get all in one pass, since there is a lot of objects
157
- const sourceVdiUuids = Object.values(backup.vdis)
158
- .map(({ other_config }) => other_config[BASE_DELTA_VDI])
159
- // full vdi don't have a base
160
- .filter(_ => !!_)
161
- // @todo use index ?
162
-
163
- const replicatedVdis = sr.$VDIs.filter(vdi => {
164
- // REPLICATED_TO_SR_UUID is not used here since we are already filtering from sr.$VDIs
165
- return vdi?.managed && !vdi?.is_a_snapshot && sourceVdiUuids.includes(vdi?.other_config[COPY_OF])
166
- })
167
211
 
168
212
  Object.values(backup.vdis).forEach(vdi => {
169
213
  vdi.other_config[COPY_OF] = vdi.uuid
@@ -172,18 +216,12 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
172
216
  vdi.other_config[REPLICATED_TO_SR_UUID] = sr.uuid
173
217
  vdi.other_config[VM_UUID] = vm.uuid
174
218
 
175
- if (sourceVdiUuids.length > 0) {
176
- const baseReplicatedTo = replicatedVdis.filter(
177
- replicatedVdi => replicatedVdi.other_config[COPY_OF] === vdi.other_config[BASE_DELTA_VDI]
178
- )
179
- assert.ok(
180
- baseReplicatedTo.length <= 1,
181
- `Target of a replication must be unique, got ${baseReplicatedTo.length} candidates`
182
- )
183
- // baseReplicatedTo can be undefined if a new disk is added and other are already replicated
184
- vdi.baseVdi = baseReplicatedTo[0]
219
+ const baseDeltaVdiUuid = vdi.other_config[BASE_DELTA_VDI]
220
+ if (baseDeltaVdiUuid !== undefined) {
221
+ // reuse the validated mapping built by checkBaseVdis
222
+ vdi.baseVdi = this.#baseVdisBySourceUuid.get(baseDeltaVdiUuid)
185
223
  } else {
186
- // first replication of this disk
224
+ // first replication of this disk (full, no base)
187
225
  vdi.baseVdi = undefined
188
226
  }
189
227
  // ensure the VDI are created on the target SR
@@ -197,7 +235,8 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
197
235
  const { _warmMigration } = this._settings
198
236
  const sr = this._sr
199
237
  const job = this._job
200
- const scheduleId = this._scheduleId
238
+ const schedule = this._schedule
239
+ const scheduleId = schedule.id
201
240
  const { uuid: srUuid, $xapi: xapi } = sr
202
241
 
203
242
  let targetVmRef
@@ -213,19 +252,26 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
213
252
  vmUuid: vm.uuid,
214
253
  srUuid,
215
254
  })
216
-
255
+ const size = Object.values(deltaExport.disks).reduce(
256
+ (sum, disk) => sum + disk.getNbGeneratedBlock() * disk.getBlockSize(),
257
+ 0
258
+ )
259
+ await xapi.setField(
260
+ 'VM',
261
+ targetVmRef,
262
+ 'name_description',
263
+ deltaExport.vm.name_description +
264
+ ` -- last replication: ${formatFilenameDate(timestamp)} ${humanFormat.bytes(size)} read`
265
+ )
217
266
  // take a snapshot to ensure these data are not modified until next snapshot
218
- await Task.run({ name: 'target snapshot' }, () =>
219
- xapi.VM_snapshot(targetVmRef, {
220
- name_label: `${vm.name_label} - ${job.name} - (${formatFilenameDate(timestamp)})`,
267
+ await Task.run({ name: 'target snapshot' }, async () => {
268
+ await xapi.VM_snapshot(targetVmRef, {
269
+ name_label: `${vm.name_label} - ${job.name} / ${schedule.name} ${formatFilenameDate(timestamp)}`,
221
270
  })
222
- )
223
- // size is mandatory to ensure the task have the right data
271
+ })
272
+
224
273
  return {
225
- size: Object.values(deltaExport.disks).reduce(
226
- (sum, disk) => sum + disk.getNbGeneratedBlock() * disk.getBlockSize(),
227
- 0
228
- ),
274
+ size,
229
275
  }
230
276
  })
231
277
  this._targetVmRef = targetVmRef
@@ -1,9 +1,9 @@
1
1
  export class AbstractWriter {
2
- constructor({ config, healthCheckSr, job, vmUuid, scheduleId, settings }) {
2
+ constructor({ config, healthCheckSr, job, vmUuid, schedule, settings }) {
3
3
  this._config = config
4
4
  this._healthCheckSr = healthCheckSr
5
5
  this._job = job
6
- this._scheduleId = scheduleId
6
+ this._schedule = schedule
7
7
  this._settings = settings
8
8
  this._vmUuid = vmUuid
9
9
  }
@@ -50,7 +50,7 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
50
50
 
51
51
  async getLongTermRetentionTags(currentEntry) {
52
52
  const settings = this._settings
53
- const scheduleId = this._scheduleId
53
+ const scheduleId = this._schedule.id
54
54
  const vmUuid = this._vmUuid
55
55
  const adapter = this._adapter
56
56
 
@@ -95,10 +95,14 @@ export class RemoteVhdDisk extends RemoteDisk {
95
95
 
96
96
  /**
97
97
  * Closes the VHD.
98
+ * We replace the dispose function call so the disk can only be closed once.
99
+ *
98
100
  * @returns {Promise<void>}
99
101
  */
100
102
  async close() {
101
- await this.#dispose()
103
+ const dispose = this.#dispose
104
+ this.#dispose = () => Promise.resolve()
105
+ await dispose()
102
106
  }
103
107
 
104
108
  /**
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.70.0",
11
+ "version": "0.71.0",
12
12
  "engines": {
13
13
  "node": ">=14.18"
14
14
  },
@@ -62,7 +62,7 @@
62
62
  "tmp": "^0.2.1"
63
63
  },
64
64
  "peerDependencies": {
65
- "@xen-orchestra/xapi": "^8.6.6"
65
+ "@xen-orchestra/xapi": "^8.7.0"
66
66
  },
67
67
  "license": "AGPL-3.0-or-later",
68
68
  "author": {