@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.
@@ -19,11 +19,14 @@ import {
19
19
  REPLICATED_TO_SR_UUID,
20
20
  DATETIME,
21
21
  VM_UUID,
22
+ CONTENT_KEY,
23
+ resetVmOtherConfig,
22
24
  } from '../../_otherConfig.mjs'
23
25
  import { formatFilenameDate } from '../../_filenameDate.mjs'
24
26
  import { XapiDiskSource } from '@xen-orchestra/xapi'
25
27
  import { asyncEach } from '@vates/async-each'
26
28
  import { createLogger } from '@xen-orchestra/log'
29
+ import { VM_POWER_STATE } from '@vates/types'
27
30
 
28
31
  const { debug } = createLogger('xo:backups:IncrementalXapiWriter')
29
32
 
@@ -32,7 +35,20 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
32
35
  // Built by checkBaseVdis, consumed by #decorateVmMetadata to set baseVdi.
33
36
  #baseVdisBySourceUuid = new Map()
34
37
 
35
- async checkBaseVdis(baseUuidToSrcVdi) {
38
+ /**
39
+ * Finds VDIs on the target SR that can serve as base for the next delta transfer.
40
+ *
41
+ * For each entry in `baseUuidToSrcVdi`, searches the target SR for a matching snapshot
42
+ * using CONTENT_KEY when available, then falls back to COPY_OF matching for older snapshots.
43
+ * Entries with no matching base found on the target SR are removed from `baseUuidToSrcVdi`.
44
+ *
45
+ * Side-effects: populates `#baseVdisBySourceUuid`; may set `_targetVmRef`.
46
+ *
47
+ * @param {Map<string, string>} baseUuidToSrcVdi - Source snapshot UUID → source active VDI UUID. Mutated in place.
48
+ * @param {Map<string, string>} contentKeys - Source snapshot UUID → CONTENT_KEY value (empty for pre-CONTENT_KEY snapshots).
49
+ * @returns {Promise<void>}
50
+ */
51
+ async checkBaseVdis(baseUuidToSrcVdi, contentKeys) {
36
52
  const sr = this._sr
37
53
  this.#baseVdisBySourceUuid = new Map()
38
54
 
@@ -45,86 +61,58 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
45
61
  // look for the same snapshot
46
62
  // ensure there are no data between the snapshot and the active disk
47
63
 
48
- const snapshotCandidates = sr.$VDIs.filter(vdi => {
49
- return (
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])
55
- )
56
- })
57
- debug('checkBaseVdis, got snapshot candidates,', snapshotCandidates.length)
58
-
59
- if (snapshotCandidates.length > 0) {
60
- // New snapshot-based flow (6.3+): verify no data was written between
61
- // the target snapshot and its active VDI.
62
- let targetVmRef
63
- let canChainToTargetVm = true
64
- await asyncEach(
65
- snapshotCandidates,
66
- async snapshot => {
67
- let diffDisk
68
- let activeVdi
69
- try {
70
- activeVdi = sr.$xapi.getObject(snapshot.$snapshot_of)
71
- const userVbds = activeVdi.$VBDs?.filter(vbd => vbd.$VM && !vbd.$VM.is_control_domain) ?? []
72
- if (userVbds.length !== 1) {
73
- debug('checkBaseVdis, share vbd ', { ref: snapshot.$ref, userVbds })
74
- // shared vdi ignore
75
- return
76
- }
77
- const vm = userVbds[0].$VM
78
- if (!('start' in vm.blocked_operations)) {
79
- debug('checkBaseVdis, vm not blocked', { vmRef: vm.$ref })
80
- // vm start unlocked
81
- // not really an issue since we have check the delta
82
- // but it indicates the users played with the blocked operations
83
- return
84
- }
85
- diffDisk = new XapiDiskSource({
86
- xapi: sr.$xapi,
87
- vdiRef: activeVdi.$ref,
88
- baseRef: snapshot.$ref,
89
- onlyListChangedBlocks: true,
90
- })
91
- await diffDisk.init()
92
- if (diffDisk.getBlockIndexes().length === 0) {
93
- const sourceUuid = snapshot.other_config?.[COPY_OF]
94
- if (sourceUuid) {
95
- this.#baseVdisBySourceUuid.set(sourceUuid, activeVdi)
96
- }
97
- // Track the target VM (the replicated VM to update on the next transfer).
98
- targetVmRef = vm.$ref
99
- } else {
100
- // not empty, we will create a new VM
101
- canChainToTargetVm = false
102
- debug('checkBaseVdis, data between snapshot and active disk', {
103
- vdiRef: snapshot.$ref,
104
- nbBlocks: diffDisk.getBlockIndexes().length,
105
- })
106
- }
107
- } catch (error) {
108
- debug('checkBaseVdis, skipping snapshot', { ref: snapshot.$ref, error })
109
- return
110
- } finally {
111
- await diffDisk?.close().catch(error => debug('checkBaseVdis, error closing', error))
112
- await sr.$xapi.VDI_disconnectFromControlDomain(snapshot.$ref)
113
- if (activeVdi !== undefined) {
114
- await sr.$xapi.VDI_disconnectFromControlDomain(activeVdi.$ref)
115
- }
116
- }
117
- },
118
- {
119
- concurrency: 4,
120
- }
121
- )
64
+ const snapshotCandidates = new Map()
65
+
66
+ for (const [baseUuid, srcVdiuid] of baseUuidToSrcVdi) {
67
+ let target
122
68
 
123
- if (canChainToTargetVm && targetVmRef !== undefined) {
124
- debug('checkBaseVdis,got a valid vm target', targetVmRef)
69
+ const contentKey = contentKeys.get(baseUuid)
70
+
71
+ if (contentKey !== undefined) {
72
+ debug('got one content key, look for a candidate')
73
+ target = sr.$VDIs.find(
74
+ vdi =>
75
+ vdi?.managed &&
76
+ vdi?.is_a_snapshot &&
77
+ vdi.other_config[CONTENT_KEY] === contentKey && // &&
78
+ // ensure we don't replicate on ourself or any of the vdi in the source chain
79
+ vdi.$snapshot_of.uuid !== srcVdiuid
80
+ )
81
+ }
82
+
83
+ // fall back for older snapshots
84
+ if (target === undefined) {
85
+ debug('content key not here or not found , look by jobid ')
86
+ target = sr.$VDIs.find(
87
+ vdi =>
88
+ vdi?.managed &&
89
+ vdi?.is_a_snapshot &&
90
+ vdi.other_config[JOB_ID] === this._job.id &&
91
+ vdi.other_config[VM_UUID] === this._vmUuid &&
92
+ vdi?.other_config[COPY_OF] === baseUuid
93
+ )
94
+ }
95
+ if (target !== undefined) {
96
+ snapshotCandidates.set(baseUuid, target)
97
+ }
98
+ }
99
+
100
+ debug('checkBaseVdis, got snapshot candidates,', snapshotCandidates.size)
101
+
102
+ if (snapshotCandidates.size > 0) {
103
+ // reset before searching for candidates
104
+ this.#baseVdisBySourceUuid = new Map()
105
+ this._targetVmRef = undefined
106
+ const { baseVdisBySourceUuid, targetVmRef } = await this.#validateSnapshotCandidates(snapshotCandidates)
107
+ for (const [sourceUuid, vdi] of baseVdisBySourceUuid) {
108
+ this.#baseVdisBySourceUuid.set(sourceUuid, vdi)
109
+ }
110
+ debug(' this.#baseVdisBySourceUuid ', this.#baseVdisBySourceUuid.size)
111
+ if (targetVmRef !== undefined) {
125
112
  this._targetVmRef = targetVmRef
126
113
  }
127
114
  } else {
115
+ debug('legacy fallback ( no content key ) ')
128
116
  // Legacy fallback (upgrade from pre-6.3): no target snapshots exist yet,
129
117
  // look for active (non-snapshot) VDIs with matching COPY_OF, like the old code did.
130
118
  debug('checkBaseVdis, no snapshot candidates, falling back to legacy active VDI lookup')
@@ -145,6 +133,108 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
145
133
  }
146
134
  }
147
135
  }
136
+ /**
137
+ * 6.3+ snapshot-based validation: for each snapshot candidate, check whether
138
+ * the active VDI has diverged from the snapshot. Returns a baseVdisBySourceUuid
139
+ * map and, when all disks are clean, the targetVmRef to reuse.
140
+ *
141
+ * @param {Map<XenApiVdi['id'], import('@vates/types').XenApiVdi>} snapshotCandidates - Snapshot VDIs on the target SR to validate.
142
+ * @returns {Promise<{ baseVdisBySourceUuid: Map<string, import('@vates/types').XenApiVdi>, targetVmRef: import('@vates/types').XenApiVm['$ref'] | undefined }>}
143
+ */
144
+ async #validateSnapshotCandidates(snapshotCandidates) {
145
+ const sr = this._sr
146
+ const baseVdisBySourceUuid = new Map()
147
+ let targetVmRef
148
+ let canChainToTargetVm = true
149
+
150
+ await asyncEach(
151
+ snapshotCandidates.entries(),
152
+ async ([sourceUuid, snapshot]) => {
153
+ let diffDisk
154
+ let activeVdi
155
+ try {
156
+ activeVdi = sr.$xapi.getObject(snapshot.$snapshot_of)
157
+ const userVbds = activeVdi.$VBDs?.filter(vbd => vbd.$VM && !vbd.$VM.is_control_domain) ?? []
158
+ if (userVbds.length !== 1) {
159
+ canChainToTargetVm = false
160
+ debug('checkBaseVdis, shared vbd ', { ref: snapshot.$ref, userVbds })
161
+ // shared vdi ignore / don't chain
162
+ return
163
+ }
164
+ const vm = userVbds[0].$VM
165
+
166
+ // a running VM will fail to compute disk exports
167
+ // also a running VM can be assumed to have changed data
168
+ if (vm.power_state !== VM_POWER_STATE.HALTED && vm.power_state !== VM_POWER_STATE.SUSPENDED) {
169
+ canChainToTargetVm = false
170
+ debug('checkBaseVdis, target vm is not halted or suspended', {
171
+ ref: snapshot.$ref,
172
+ userVbds,
173
+ powerState: vm.power_state,
174
+ })
175
+ }
176
+ // from this disk of from another
177
+ // skip the costly part, only do the disk chaining
178
+ if (!canChainToTargetVm) {
179
+ debug("Can't chain VM anyway , fast return and chain with snapshot")
180
+ baseVdisBySourceUuid.set(sourceUuid, snapshot)
181
+ return
182
+ }
183
+ diffDisk = new XapiDiskSource({
184
+ xapi: sr.$xapi,
185
+ vdiRef: activeVdi.$ref,
186
+ baseRef: snapshot.$ref,
187
+ onlyListChangedBlocks: true,
188
+ })
189
+ await diffDisk.init()
190
+ if (diffDisk.getBlockIndexes().length === 0) {
191
+ debug(' NO CHANGE , source detected ? ', !!sourceUuid)
192
+ // no block modification since the common snapshot, we can chain VM and disk
193
+ // the disk is chained with the active to keep the chain linear
194
+ baseVdisBySourceUuid.set(sourceUuid, activeVdi)
195
+ // Track the target VM (the replicated VM to update on the next transfer).
196
+ targetVmRef = vm.$ref
197
+ } else {
198
+ debug(' GOT CHANGE, source detected ? ', !!sourceUuid)
199
+ baseVdisBySourceUuid.set(sourceUuid, snapshot)
200
+ // there are changed block since the snapshot
201
+ // we can reuse it to transfer a delta, but we will
202
+ // create a new VM
203
+ canChainToTargetVm = false
204
+ debug('checkBaseVdis, data between snapshot and active disk', {
205
+ vdiRef: snapshot.$ref,
206
+ nbBlocks: diffDisk.getBlockIndexes().length,
207
+ })
208
+ }
209
+ } catch (error) {
210
+ debug('checkBaseVdis, skipping snapshot', { ref: snapshot.$ref, error })
211
+ return
212
+ } finally {
213
+ await diffDisk?.close().catch(error => debug('checkBaseVdis, error closing', error))
214
+ await sr.$xapi.VDI_disconnectFromControlDomain(snapshot.$ref)
215
+ if (activeVdi !== undefined) {
216
+ await sr.$xapi.VDI_disconnectFromControlDomain(activeVdi.$ref)
217
+ }
218
+ }
219
+ },
220
+ {
221
+ concurrency: 4,
222
+ }
223
+ )
224
+
225
+ if (!canChainToTargetVm) {
226
+ debug('checkBaseVdis,NOT a valid vm target')
227
+ // if at least one disk has new data, create a new VM
228
+ // instead of updating it
229
+ targetVmRef = undefined
230
+ } else if (targetVmRef !== undefined) {
231
+ debug('checkBaseVdis,got a valid vm target', targetVmRef)
232
+ }
233
+
234
+ debug('checkBaseVdis,base vdis found : ', baseVdisBySourceUuid.size)
235
+ return { baseVdisBySourceUuid, targetVmRef }
236
+ }
237
+
148
238
  updateUuidAndChain() {
149
239
  // nothing to do, the chaining is not modified in this case
150
240
  }
@@ -239,6 +329,10 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
239
329
  }
240
330
 
241
331
  Object.values(backup.vdis).forEach(vdi => {
332
+ // setVmSnapshotContentKeys sets CONTENT_KEY = the snapshot VDI's own UUID, but
333
+ // that XenAPI write may not be visible in the xapi cache yet when exportIncrementalVm
334
+ // reads vdi.other_config. Derive the correct value directly instead of relying on cache.
335
+ vdi.other_config[CONTENT_KEY] = vdi.uuid
242
336
  vdi.other_config[COPY_OF] = vdi.uuid
243
337
  vdi.other_config[JOB_ID] = job.id
244
338
  vdi.other_config[SCHEDULE_ID] = scheduleId
@@ -297,6 +391,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
297
391
  await xapi.VM_snapshot(targetVmRef, {
298
392
  name_label: `${vm.name_label} - ${job.name} / ${schedule.name} ${formatFilenameDate(timestamp)}`,
299
393
  })
394
+ await resetVmOtherConfig(xapi, targetVmRef)
300
395
  })
301
396
 
302
397
  return {
@@ -31,7 +31,6 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
31
31
  return await Task.run({ properties: { name: 'clean-vm' } }, () => {
32
32
  return this._adapter.cleanVm(this._vmBackupDir, {
33
33
  ...options,
34
- fixMetadata: true,
35
34
  logInfo: info,
36
35
  logWarn: (message, data) => {
37
36
  warn(message, data)
@@ -39,7 +38,6 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
39
38
  },
40
39
  lock: false,
41
40
  mergeBlockConcurrency: this._config.mergeBlockConcurrency,
42
- removeTmp: true,
43
41
  })
44
42
  })
45
43
  } catch (error) {
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "type": "git",
9
9
  "url": "https://github.com/vatesfr/xen-orchestra.git"
10
10
  },
11
- "version": "0.72.0",
11
+ "version": "0.73.0",
12
12
  "engines": {
13
13
  "node": ">=14.18"
14
14
  },
@@ -30,12 +30,14 @@
30
30
  "@vates/nbd-client": "^3.4.0",
31
31
  "@vates/parse-duration": "^0.1.1",
32
32
  "@vates/task": "^0.7.0",
33
+ "@vates/types": "^1.25.0",
33
34
  "@xen-orchestra/async-map": "^0.1.3",
34
- "@xen-orchestra/disk-transform": "^1.2.3",
35
- "@xen-orchestra/fs": "^4.8.0",
35
+ "@xen-orchestra/disk-transform": "^1.3.0",
36
+ "@xen-orchestra/fs": "^4.9.0",
36
37
  "@xen-orchestra/log": "^0.7.2",
37
38
  "@xen-orchestra/qcow2": "^1.3.0",
38
39
  "@xen-orchestra/template": "^0.1.1",
40
+ "@xen-orchestra/backup-archive": "^2.0.0",
39
41
  "app-conf": "^3.0.0",
40
42
  "compare-versions": "^6.0.0",
41
43
  "d3-time-format": "^4.1.0",
@@ -61,10 +63,11 @@
61
63
  "fs-extra": "^11.1.0",
62
64
  "rimraf": "^6.0.1",
63
65
  "sinon": "^18.0.0",
64
- "tmp": "^0.2.1"
66
+ "tmp": "^0.2.1",
67
+ "typescript": "^5.9.3"
65
68
  },
66
69
  "peerDependencies": {
67
- "@xen-orchestra/xapi": "^8.7.2"
70
+ "@xen-orchestra/xapi": "^8.8.0"
68
71
  },
69
72
  "license": "AGPL-3.0-or-later",
70
73
  "author": {
@@ -72,7 +75,6 @@
72
75
  "url": "https://vates.fr"
73
76
  },
74
77
  "exports": {
75
- "./disks": "./disks/index.mjs",
76
78
  "./*": "./*"
77
79
  }
78
80
  }
@@ -0,0 +1,47 @@
1
+ export namespace VHDFOOTER {
2
+ let cookie: string
3
+ let features: number
4
+ let fileFormatVersion: number
5
+ let dataOffset: number
6
+ let timestamp: number
7
+ let creatorApplication: string
8
+ let creatorVersion: number
9
+ let creatorHostOs: number
10
+ let originalSize: number
11
+ let currentSize: number
12
+ namespace diskGeometry {
13
+ let cylinders: number
14
+ let heads: number
15
+ let sectorsPerTrackCylinder: number
16
+ }
17
+ let diskType: number
18
+ let checksum: number
19
+ let uuid: Buffer<ArrayBuffer>
20
+ let saved: string
21
+ let hidden: string
22
+ let reserved: string
23
+ }
24
+ export namespace VHDHEADER {
25
+ let cookie_1: string
26
+ export { cookie_1 as cookie }
27
+ let dataOffset_1: any
28
+ export { dataOffset_1 as dataOffset }
29
+ export let tableOffset: number
30
+ export let headerVersion: number
31
+ export let maxTableEntries: number
32
+ export let blockSize: number
33
+ let checksum_1: number
34
+ export { checksum_1 as checksum }
35
+ export let parentUuid: any
36
+ export let parentTimestamp: number
37
+ export let reserved1: number
38
+ export let parentUnicodeName: string
39
+ export let parentLocatorEntry: {
40
+ platformCode: number
41
+ platformDataSpace: number
42
+ platformDataLength: number
43
+ reserved: number
44
+ platformDataOffset: number
45
+ }[]
46
+ export let reserved2: string
47
+ }