@xen-orchestra/backups 0.72.1 → 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.
- package/ImportVmBackup.mjs +1 -1
- package/RemoteAdapter.mjs +8 -6
- package/_otherConfig.mjs +2 -0
- package/_runners/_vmRunners/IncrementalXapi.mjs +115 -41
- package/_runners/_vmRunners/_AbstractXapi.mjs +38 -34
- package/_runners/_writers/IncrementalXapiWriter.mjs +92 -27
- package/_runners/_writers/_MixinRemoteWriter.mjs +0 -2
- package/package.json +8 -6
- package/tests.fixtures.d.mts +47 -0
- package/_cleanVm.mjs +0 -623
- package/disks/MergeRemoteDisk.mjs +0 -324
- package/disks/RemoteDisk.mjs +0 -223
- package/disks/RemoteVhdDisk.mjs +0 -480
- package/disks/RemoteVhdDiskChain.mjs +0 -304
- package/disks/index.mjs +0 -49
- package/disks/openDiskChain.mjs +0 -40
package/ImportVmBackup.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { dirname, join } from 'node:path'
|
|
|
10
10
|
import pickBy from 'lodash/pickBy.js'
|
|
11
11
|
import { defer } from 'golike-defer'
|
|
12
12
|
import { NegativeDisk } from '@xen-orchestra/disk-transform'
|
|
13
|
-
import { openDiskChain } from '
|
|
13
|
+
import { openDiskChain } from '@xen-orchestra/backup-archive/disks'
|
|
14
14
|
import { resetVmOtherConfig } from './_otherConfig.mjs'
|
|
15
15
|
|
|
16
16
|
const { debug, info, warn } = createLogger('xo:backups:importVmBackup')
|
package/RemoteAdapter.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import * as tar from 'tar'
|
|
|
22
22
|
import zlib from 'zlib'
|
|
23
23
|
|
|
24
24
|
import { BACKUP_DIR } from './_getVmBackupDir.mjs'
|
|
25
|
-
import {
|
|
25
|
+
import { VmBackupDirectory } from '@xen-orchestra/backup-archive'
|
|
26
26
|
import { formatFilenameDate } from './_filenameDate.mjs'
|
|
27
27
|
import { getTmpDir } from './_getTmpDir.mjs'
|
|
28
28
|
import { isMetadataFile } from './_backupType.mjs'
|
|
@@ -31,8 +31,7 @@ import { listPartitions, LVM_PARTITION_TYPE_MBR, LVM_PARTITION_TYPE_GPT } from '
|
|
|
31
31
|
import { lvs, pvs } from './_lvm.mjs'
|
|
32
32
|
import { watchStreamSize } from './_watchStreamSize.mjs'
|
|
33
33
|
|
|
34
|
-
import { RemoteVhdDisk } from '
|
|
35
|
-
import { openDiskChain } from './disks/openDiskChain.mjs'
|
|
34
|
+
import { RemoteVhdDisk, openDiskChain } from '@xen-orchestra/backup-archive/disks'
|
|
36
35
|
import { toVhdStream, writeToVhdDirectory } from 'vhd-lib/disk-consumer/index.mjs'
|
|
37
36
|
import { ReadAhead } from '@xen-orchestra/disk-transform'
|
|
38
37
|
|
|
@@ -879,11 +878,14 @@ export class RemoteAdapter {
|
|
|
879
878
|
}
|
|
880
879
|
|
|
881
880
|
Object.assign(RemoteAdapter.prototype, {
|
|
882
|
-
cleanVm(
|
|
881
|
+
cleanVm(vmBackupPath, opts = {}) {
|
|
882
|
+
const { lock = true, ...cleanOpts } = opts
|
|
883
883
|
if (lock) {
|
|
884
|
-
return Disposable.use(this._handler.lock(
|
|
884
|
+
return Disposable.use(this._handler.lock(vmBackupPath), () => {
|
|
885
|
+
return VmBackupDirectory.cleanVm(this._handler, vmBackupPath, cleanOpts)
|
|
886
|
+
})
|
|
885
887
|
} else {
|
|
886
|
-
return cleanVm
|
|
888
|
+
return VmBackupDirectory.cleanVm(this._handler, vmBackupPath, cleanOpts)
|
|
887
889
|
}
|
|
888
890
|
},
|
|
889
891
|
isValidXva,
|
package/_otherConfig.mjs
CHANGED
|
@@ -85,6 +85,7 @@ export async function getVmDeltaChainLength(xapi, vmRef) {
|
|
|
85
85
|
export function resetVmOtherConfig(xapi, vmRef) {
|
|
86
86
|
return applyToVmAndVdis(xapi, vmRef, (type, ref) => {
|
|
87
87
|
return xapi.setFieldEntries(type, ref, 'other_config', {
|
|
88
|
+
[CONTENT_KEY]: null,
|
|
88
89
|
[COPY_OF]: null,
|
|
89
90
|
[DATETIME]: null,
|
|
90
91
|
[DELTA_CHAIN_LENGTH]: null,
|
|
@@ -92,6 +93,7 @@ export function resetVmOtherConfig(xapi, vmRef) {
|
|
|
92
93
|
[JOB_ID]: null,
|
|
93
94
|
[SCHEDULE_ID]: null,
|
|
94
95
|
[VM_UUID]: null,
|
|
96
|
+
|
|
95
97
|
// REPLICATED_TO_SR_UUID is not reset since we can replicate a replication
|
|
96
98
|
})
|
|
97
99
|
})
|
|
@@ -8,6 +8,7 @@ import { exportIncrementalVm } from '../../_incrementalVm.mjs'
|
|
|
8
8
|
import { IncrementalRemoteWriter } from '../_writers/IncrementalRemoteWriter.mjs'
|
|
9
9
|
import { IncrementalXapiWriter } from '../_writers/IncrementalXapiWriter.mjs'
|
|
10
10
|
import {
|
|
11
|
+
CONTENT_KEY,
|
|
11
12
|
DATETIME,
|
|
12
13
|
DELTA_CHAIN_LENGTH,
|
|
13
14
|
EXPORTED_SUCCESSFULLY,
|
|
@@ -101,63 +102,136 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
|
|
|
101
102
|
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Groups snapshot VDIs by their DATETIME other_config value and sorts them most-recent-first.
|
|
107
|
+
*
|
|
108
|
+
* @param {import('@vates/types').XenApiVdi[]} snapshotVdis
|
|
109
|
+
* @returns {{ datetime: string, vdis: import('@vates/types').XenApiVdi[] }[]}
|
|
110
|
+
*/
|
|
111
|
+
_groupSnapshotVdisByDatetime(snapshotVdis) {
|
|
112
|
+
const byDatetime = new Map()
|
|
113
|
+
for (const vdi of snapshotVdis) {
|
|
114
|
+
const datetime = vdi.other_config[DATETIME]
|
|
115
|
+
if (!byDatetime.has(datetime)) {
|
|
116
|
+
byDatetime.set(datetime, [])
|
|
117
|
+
}
|
|
118
|
+
byDatetime.get(datetime).push(vdi)
|
|
119
|
+
}
|
|
120
|
+
// sort them by decreasing datetime
|
|
121
|
+
return [...byDatetime.entries()]
|
|
122
|
+
.sort(([a], [b]) => b.localeCompare(a))
|
|
123
|
+
.map(([datetime, vdis]) => ({ datetime, vdis }))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* For each snapshot VDI in `snapshotVdis`, maps it to its live source VDI,
|
|
128
|
+
* attaches its CONTENT_KEY when present, calls checkBaseVdis on all writers,
|
|
129
|
+
* and returns both the full mapping and the subset confirmed present by writers.
|
|
130
|
+
*
|
|
131
|
+
* @param {import('@vates/types').XenApiVdi[]} snapshotVdis - Snapshot VDI records to use as base candidates
|
|
132
|
+
* @param {Record<string, object>} srcVdisByRef - Live VM VDI records keyed by $ref
|
|
133
|
+
* @param {{ useContentKey?: boolean }} [options]
|
|
134
|
+
* @returns {Promise<{ presentBaseVdis: Map<string, string>, baseUuidToSrcVdiUuid: Map<string, string>}>}
|
|
135
|
+
*/
|
|
136
|
+
async _checkBaseVdis(snapshotVdis, srcVdisByRef, { useContentKey = false } = {}) {
|
|
137
|
+
const baseUuidToSrcVdiUuid = new Map()
|
|
138
|
+
const baseUuidToContentKey = new Map()
|
|
139
|
+
for (const snapshotVdi of snapshotVdis) {
|
|
140
|
+
const baseUuid = snapshotVdi.uuid
|
|
141
|
+
const srcVdi = srcVdisByRef[snapshotVdi.snapshot_of]
|
|
142
|
+
if (srcVdi !== undefined) {
|
|
143
|
+
baseUuidToSrcVdiUuid.set(baseUuid, srcVdi.uuid)
|
|
144
|
+
if (useContentKey) {
|
|
145
|
+
const contentKey = snapshotVdi.other_config[CONTENT_KEY]
|
|
146
|
+
if (contentKey !== undefined) {
|
|
147
|
+
baseUuidToContentKey.set(baseUuid, contentKey)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
debug('ignore snapshot VDI because no longer present on VM', { vdi: baseUuid })
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const presentBaseVdis = new Map(baseUuidToSrcVdiUuid)
|
|
156
|
+
await this._callWriters(
|
|
157
|
+
writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis, baseUuidToContentKey),
|
|
158
|
+
'writer.checkBaseVdis()',
|
|
159
|
+
false
|
|
160
|
+
)
|
|
161
|
+
return { presentBaseVdis, baseUuidToSrcVdiUuid }
|
|
162
|
+
}
|
|
163
|
+
|
|
104
164
|
async _selectBaseVm() {
|
|
105
165
|
const xapi = this._xapi
|
|
106
166
|
|
|
107
|
-
// filter _jobSnapshotVdis to have only the last successfully exported vdi
|
|
108
|
-
// compute delta chain length
|
|
109
|
-
// fill baseUuidToSrcVdi with this data
|
|
110
|
-
|
|
111
167
|
const exportedVdis = this._jobSnapshotVdis.filter(
|
|
112
168
|
_ => EXPORTED_SUCCESSFULLY in _.other_config && DATETIME in _.other_config
|
|
113
169
|
)
|
|
114
|
-
|
|
115
|
-
let lastExportedVdis = []
|
|
116
|
-
for (const exportedVdi of exportedVdis) {
|
|
117
|
-
if (lastSuccessfullBackup === undefined || lastSuccessfullBackup < exportedVdi.other_config[DATETIME]) {
|
|
118
|
-
lastExportedVdis = []
|
|
119
|
-
lastSuccessfullBackup = exportedVdi.other_config[DATETIME]
|
|
120
|
-
}
|
|
121
|
-
if (lastSuccessfullBackup === exportedVdi.other_config[DATETIME]) {
|
|
122
|
-
lastExportedVdis.push(exportedVdi)
|
|
123
|
-
}
|
|
124
|
-
}
|
|
170
|
+
const grouped = this._groupSnapshotVdisByDatetime(exportedVdis)
|
|
125
171
|
|
|
126
172
|
this._baseVdis = {}
|
|
127
|
-
if (
|
|
128
|
-
debug('no base
|
|
173
|
+
if (grouped.length === 0) {
|
|
174
|
+
debug('no base VDIs found', {
|
|
129
175
|
jobLength: this._jobSnapshotVdis.length,
|
|
130
|
-
lastSuccessfullBackup,
|
|
131
176
|
exportedLength: exportedVdis.length,
|
|
132
177
|
})
|
|
133
|
-
return
|
|
134
178
|
}
|
|
135
|
-
const deltaChainLength = Math.max(
|
|
136
|
-
...lastExportedVdis.map(({ other_config }) => Number(other_config[DELTA_CHAIN_LENGTH] ?? 0))
|
|
137
|
-
)
|
|
138
|
-
|
|
139
179
|
const srcVdis = keyBy(await xapi.getRecords('VDI', await this._vm.$getDisks()), '$ref')
|
|
180
|
+
let lastExportedVdis = []
|
|
181
|
+
let presentBaseVdis = new Map()
|
|
182
|
+
let baseUuidToSrcVdiUuid = new Map()
|
|
183
|
+
let deltaChainLength
|
|
140
184
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
} else {
|
|
149
|
-
debug('ignore snapshot VDI because no longer present on VM', {
|
|
150
|
-
vdi: baseUuid,
|
|
151
|
-
})
|
|
152
|
-
}
|
|
185
|
+
if (grouped.length > 0) {
|
|
186
|
+
lastExportedVdis = grouped[0].vdis
|
|
187
|
+
// only compute it on the _jobSnapshotVdis , not on the reused snapshot from CONTENT_KEYS
|
|
188
|
+
deltaChainLength = Math.max(
|
|
189
|
+
...lastExportedVdis.map(({ other_config }) => Number(other_config[DELTA_CHAIN_LENGTH] ?? 0))
|
|
190
|
+
)
|
|
191
|
+
;({ presentBaseVdis, baseUuidToSrcVdiUuid } = await this._checkBaseVdis(lastExportedVdis, srcVdis))
|
|
153
192
|
}
|
|
154
193
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
194
|
+
// if we don't find any candidates
|
|
195
|
+
// if there is only one writer of type IncrementalXapiWriter
|
|
196
|
+
// allow the reuse of any matching snapshot of this VM even from another job
|
|
197
|
+
if (
|
|
198
|
+
presentBaseVdis.size === 0 &&
|
|
199
|
+
this._writers.size === 1 &&
|
|
200
|
+
[...this._writers][0] instanceof IncrementalXapiWriter
|
|
201
|
+
) {
|
|
202
|
+
debug('fallback to content key')
|
|
203
|
+
const candidates = {}
|
|
204
|
+
for (const srcVdi of Object.values(srcVdis)) {
|
|
205
|
+
const snapshots = srcVdi.$snapshots
|
|
206
|
+
debug(`${srcVdi.uuid} got ${snapshots.length} snapshots`)
|
|
207
|
+
for (const snapshot of snapshots) {
|
|
208
|
+
if (DATETIME in snapshot.other_config) {
|
|
209
|
+
candidates[snapshot.uuid] = snapshot
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
debug('got candidates ', Object.keys(candidates).length)
|
|
215
|
+
this._filterValidSnapshotVdis(candidates)
|
|
216
|
+
debug('got filtered ', Object.keys(candidates).length)
|
|
217
|
+
for (const group of this._groupSnapshotVdisByDatetime(Object.values(candidates))) {
|
|
218
|
+
debug('check by datetime ', [...group.vdis].length)
|
|
219
|
+
debug(group.vdis)
|
|
220
|
+
const result = await this._checkBaseVdis(group.vdis, srcVdis, { useContentKey: true })
|
|
221
|
+
debug('result ', result)
|
|
222
|
+
if (result.presentBaseVdis.size > 0) {
|
|
223
|
+
lastExportedVdis = group.vdis
|
|
224
|
+
|
|
225
|
+
presentBaseVdis = result.presentBaseVdis
|
|
226
|
+
baseUuidToSrcVdiUuid = result.baseUuidToSrcVdiUuid
|
|
227
|
+
|
|
228
|
+
break
|
|
229
|
+
}
|
|
230
|
+
debug('try next group if any')
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
debug(' no fallback ')
|
|
234
|
+
}
|
|
161
235
|
|
|
162
236
|
if (presentBaseVdis.size === 0) {
|
|
163
237
|
debug('no base VM found')
|
|
@@ -236,28 +236,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
236
236
|
// ensure they are attached to only one vm snapshot
|
|
237
237
|
// ensure any VM-snapshot harvested by this has all its disk harvested (no mix of vdi snapshot from this job and not)
|
|
238
238
|
|
|
239
|
-
|
|
240
|
-
const jobId = this._jobId
|
|
241
|
-
const xapi = this._xapi
|
|
242
|
-
|
|
243
|
-
const vdiCandidates = {}
|
|
244
|
-
const vdiUuids = this._vm.$VBDs.map(({ VDI }) => VDI)
|
|
245
|
-
Object.values(xapi.objects.indexes.type.VDI)
|
|
246
|
-
.filter(_ => !!_) // filter nullish
|
|
247
|
-
.filter(({ other_config, snapshot_of }) => {
|
|
248
|
-
return (
|
|
249
|
-
vdiUuids.includes(snapshot_of) &&
|
|
250
|
-
other_config[JOB_ID] === jobId &&
|
|
251
|
-
other_config[VM_UUID] === this._vm.uuid &&
|
|
252
|
-
other_config[COPY_OF] === undefined
|
|
253
|
-
)
|
|
254
|
-
})
|
|
255
|
-
.forEach(vdi => {
|
|
256
|
-
vdiCandidates[vdi.uuid] = vdi
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
// check that user snapshots are clean
|
|
260
|
-
|
|
239
|
+
_filterValidSnapshotVdis(vdiCandidates) {
|
|
261
240
|
for (const vdi of Object.values(vdiCandidates)) {
|
|
262
241
|
// cbt metadata are always considered linked to a backup job
|
|
263
242
|
// if they have the right other_config
|
|
@@ -279,7 +258,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
279
258
|
const userVms = vbds.map(({ $VM }) => $VM)
|
|
280
259
|
if (vbds.length > 1) {
|
|
281
260
|
warn(
|
|
282
|
-
`vdi ${vdi.name_label} (${vdi.uuid}) is linked to multipe vms : ${userVms.map(({ name_label, uuid }) => `${name_label} ${uuid}`).join(', ')}.
|
|
261
|
+
`vdi ${vdi.name_label} (${vdi.uuid}) is linked to multipe vms : ${userVms.map(({ name_label, uuid }) => `${name_label} ${uuid}`).join(', ')}.
|
|
283
262
|
This disk snapshot will be excluded from the backup cleaning`,
|
|
284
263
|
{ vdi, userVms }
|
|
285
264
|
)
|
|
@@ -294,7 +273,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
294
273
|
// => we exclude these from the backup processing
|
|
295
274
|
if (vm.$snapshot_of === undefined) {
|
|
296
275
|
warn(
|
|
297
|
-
`vdi ${vdi.name_label} (${vdi.uuid}) is a snapshot linked to a non snapshot vm ${vm.name_label} ${vm.uuid}.
|
|
276
|
+
`vdi ${vdi.name_label} (${vdi.uuid}) is a snapshot linked to a non snapshot vm ${vm.name_label} ${vm.uuid}.
|
|
298
277
|
This disk snapshot will be excluded from the backup cleaning`,
|
|
299
278
|
{ vdi, vm }
|
|
300
279
|
)
|
|
@@ -311,7 +290,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
311
290
|
vm.other_config[VM_UUID] !== vdi.other_config[VM_UUID]
|
|
312
291
|
) {
|
|
313
292
|
warn(
|
|
314
|
-
`vdi ${vdi.name_label} (${vdi.uuid}) is a snapshot linked to a snapshot vm ${vm.name_label} ${vm.uuid} out of this backup job scope.
|
|
293
|
+
`vdi ${vdi.name_label} (${vdi.uuid}) is a snapshot linked to a snapshot vm ${vm.name_label} ${vm.uuid} out of this backup job scope.
|
|
315
294
|
This disk snapshot will be excluded from the backup cleaning`,
|
|
316
295
|
{ vdi, vm }
|
|
317
296
|
)
|
|
@@ -328,7 +307,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
328
307
|
.forEach(({ $VDI: outOfSnapshotsVdi, ...other }) => {
|
|
329
308
|
warn(
|
|
330
309
|
`vdi ${vdi.name_label} ${vdi.uuid} is recognized as a snapshot of the backup job,
|
|
331
|
-
linked to vm ${vm.name_label} ${vm.uuid} but vdi ${outOfSnapshotsVdi.name_label} ${outOfSnapshotsVdi.uuid}
|
|
310
|
+
linked to vm ${vm.name_label} ${vm.uuid} but vdi ${outOfSnapshotsVdi.name_label} ${outOfSnapshotsVdi.uuid}
|
|
332
311
|
is not linked to the job. This disk snapshot will be excluded from the backup cleaning`,
|
|
333
312
|
{ vdi, vm, vbds, outOfSnapshotsVdi }
|
|
334
313
|
)
|
|
@@ -336,19 +315,44 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
336
315
|
delete vdiCandidates[vdi.uuid]
|
|
337
316
|
})
|
|
338
317
|
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async _fetchJobSnapshots() {
|
|
321
|
+
const jobId = this._jobId
|
|
322
|
+
const xapi = this._xapi
|
|
323
|
+
|
|
324
|
+
const vdiCandidates = {}
|
|
325
|
+
const vdiUuids = this._vm.$VBDs.map(({ VDI }) => VDI)
|
|
326
|
+
Object.values(xapi.objects.indexes.type.VDI)
|
|
327
|
+
.filter(_ => !!_) // filter nullish
|
|
328
|
+
.filter(({ other_config, snapshot_of }) => {
|
|
329
|
+
return (
|
|
330
|
+
vdiUuids.includes(snapshot_of) &&
|
|
331
|
+
other_config[JOB_ID] === jobId &&
|
|
332
|
+
other_config[VM_UUID] === this._vm.uuid &&
|
|
333
|
+
other_config[COPY_OF] === undefined
|
|
334
|
+
)
|
|
335
|
+
})
|
|
336
|
+
.forEach(vdi => {
|
|
337
|
+
vdiCandidates[vdi.uuid] = vdi
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
this._filterValidSnapshotVdis(vdiCandidates)
|
|
339
341
|
|
|
340
342
|
this._jobSnapshotVdis = Object.values(vdiCandidates)
|
|
341
343
|
|
|
342
344
|
// For VMs with no disks, retention must be tracked directly on VM snapshots
|
|
343
345
|
// since there are no VDIs to anchor the discovery.
|
|
344
346
|
if (vdiUuids.length === 0) {
|
|
345
|
-
this._disklessJobSnapshotVms = this._vm.$snapshots
|
|
346
|
-
(
|
|
347
|
-
|
|
348
|
-
other_config
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
347
|
+
this._disklessJobSnapshotVms = this._vm.$snapshots
|
|
348
|
+
.filter(Boolean)
|
|
349
|
+
.filter(
|
|
350
|
+
({ other_config, $snapshot_of }) =>
|
|
351
|
+
$snapshot_of !== undefined &&
|
|
352
|
+
other_config[JOB_ID] === jobId &&
|
|
353
|
+
other_config[VM_UUID] === this._vm.uuid &&
|
|
354
|
+
other_config[COPY_OF] === undefined
|
|
355
|
+
)
|
|
352
356
|
} else {
|
|
353
357
|
this._disklessJobSnapshotVms = []
|
|
354
358
|
}
|
|
@@ -447,7 +451,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
447
451
|
if (disklessVmSnapshots.length > 0) {
|
|
448
452
|
const snapshotsPerSchedule = groupBy(disklessVmSnapshots, _ => _.other_config[SCHEDULE_ID])
|
|
449
453
|
await asyncEach(Object.entries(snapshotsPerSchedule), async ([scheduleId, snapshots]) => {
|
|
450
|
-
// we only have one snapshot per date time since it's at the VM level
|
|
454
|
+
// we only have one snapshot per date time since it's at the VM level
|
|
451
455
|
const snapshotPerDatetime = Object.fromEntries(snapshots.map(s => [s.other_config[DATETIME], s.$ref]))
|
|
452
456
|
const datetimes = Object.keys(snapshotPerDatetime).sort()
|
|
453
457
|
const settings = {
|
|
@@ -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
|
-
|
|
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,18 +61,45 @@ 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 =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
|
|
64
|
+
const snapshotCandidates = new Map()
|
|
65
|
+
|
|
66
|
+
for (const [baseUuid, srcVdiuid] of baseUuidToSrcVdi) {
|
|
67
|
+
let target
|
|
68
|
+
|
|
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
|
+
}
|
|
58
99
|
|
|
59
|
-
|
|
100
|
+
debug('checkBaseVdis, got snapshot candidates,', snapshotCandidates.size)
|
|
101
|
+
|
|
102
|
+
if (snapshotCandidates.size > 0) {
|
|
60
103
|
// reset before searching for candidates
|
|
61
104
|
this.#baseVdisBySourceUuid = new Map()
|
|
62
105
|
this._targetVmRef = undefined
|
|
@@ -64,10 +107,12 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
64
107
|
for (const [sourceUuid, vdi] of baseVdisBySourceUuid) {
|
|
65
108
|
this.#baseVdisBySourceUuid.set(sourceUuid, vdi)
|
|
66
109
|
}
|
|
110
|
+
debug(' this.#baseVdisBySourceUuid ', this.#baseVdisBySourceUuid.size)
|
|
67
111
|
if (targetVmRef !== undefined) {
|
|
68
112
|
this._targetVmRef = targetVmRef
|
|
69
113
|
}
|
|
70
114
|
} else {
|
|
115
|
+
debug('legacy fallback ( no content key ) ')
|
|
71
116
|
// Legacy fallback (upgrade from pre-6.3): no target snapshots exist yet,
|
|
72
117
|
// look for active (non-snapshot) VDIs with matching COPY_OF, like the old code did.
|
|
73
118
|
debug('checkBaseVdis, no snapshot candidates, falling back to legacy active VDI lookup')
|
|
@@ -92,6 +137,9 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
92
137
|
* 6.3+ snapshot-based validation: for each snapshot candidate, check whether
|
|
93
138
|
* the active VDI has diverged from the snapshot. Returns a baseVdisBySourceUuid
|
|
94
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 }>}
|
|
95
143
|
*/
|
|
96
144
|
async #validateSnapshotCandidates(snapshotCandidates) {
|
|
97
145
|
const sr = this._sr
|
|
@@ -100,24 +148,36 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
100
148
|
let canChainToTargetVm = true
|
|
101
149
|
|
|
102
150
|
await asyncEach(
|
|
103
|
-
snapshotCandidates,
|
|
104
|
-
async snapshot => {
|
|
151
|
+
snapshotCandidates.entries(),
|
|
152
|
+
async ([sourceUuid, snapshot]) => {
|
|
105
153
|
let diffDisk
|
|
106
154
|
let activeVdi
|
|
107
155
|
try {
|
|
108
156
|
activeVdi = sr.$xapi.getObject(snapshot.$snapshot_of)
|
|
109
157
|
const userVbds = activeVdi.$VBDs?.filter(vbd => vbd.$VM && !vbd.$VM.is_control_domain) ?? []
|
|
110
158
|
if (userVbds.length !== 1) {
|
|
159
|
+
canChainToTargetVm = false
|
|
111
160
|
debug('checkBaseVdis, shared vbd ', { ref: snapshot.$ref, userVbds })
|
|
112
|
-
// shared vdi ignore
|
|
161
|
+
// shared vdi ignore / don't chain
|
|
113
162
|
return
|
|
114
163
|
}
|
|
115
164
|
const vm = userVbds[0].$VM
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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)
|
|
121
181
|
return
|
|
122
182
|
}
|
|
123
183
|
diffDisk = new XapiDiskSource({
|
|
@@ -127,18 +187,16 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
127
187
|
onlyListChangedBlocks: true,
|
|
128
188
|
})
|
|
129
189
|
await diffDisk.init()
|
|
130
|
-
const sourceUuid = snapshot.other_config?.[COPY_OF]
|
|
131
190
|
if (diffDisk.getBlockIndexes().length === 0) {
|
|
191
|
+
debug(' NO CHANGE , source detected ? ', !!sourceUuid)
|
|
132
192
|
// no block modification since the common snapshot, we can chain VM and disk
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
193
|
+
// the disk is chained with the active to keep the chain linear
|
|
194
|
+
baseVdisBySourceUuid.set(sourceUuid, activeVdi)
|
|
136
195
|
// Track the target VM (the replicated VM to update on the next transfer).
|
|
137
196
|
targetVmRef = vm.$ref
|
|
138
197
|
} else {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
198
|
+
debug(' GOT CHANGE, source detected ? ', !!sourceUuid)
|
|
199
|
+
baseVdisBySourceUuid.set(sourceUuid, snapshot)
|
|
142
200
|
// there are changed block since the snapshot
|
|
143
201
|
// we can reuse it to transfer a delta, but we will
|
|
144
202
|
// create a new VM
|
|
@@ -165,6 +223,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
165
223
|
)
|
|
166
224
|
|
|
167
225
|
if (!canChainToTargetVm) {
|
|
226
|
+
debug('checkBaseVdis,NOT a valid vm target')
|
|
168
227
|
// if at least one disk has new data, create a new VM
|
|
169
228
|
// instead of updating it
|
|
170
229
|
targetVmRef = undefined
|
|
@@ -172,6 +231,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
172
231
|
debug('checkBaseVdis,got a valid vm target', targetVmRef)
|
|
173
232
|
}
|
|
174
233
|
|
|
234
|
+
debug('checkBaseVdis,base vdis found : ', baseVdisBySourceUuid.size)
|
|
175
235
|
return { baseVdisBySourceUuid, targetVmRef }
|
|
176
236
|
}
|
|
177
237
|
|
|
@@ -269,6 +329,10 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
269
329
|
}
|
|
270
330
|
|
|
271
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
|
|
272
336
|
vdi.other_config[COPY_OF] = vdi.uuid
|
|
273
337
|
vdi.other_config[JOB_ID] = job.id
|
|
274
338
|
vdi.other_config[SCHEDULE_ID] = scheduleId
|
|
@@ -327,6 +391,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
327
391
|
await xapi.VM_snapshot(targetVmRef, {
|
|
328
392
|
name_label: `${vm.name_label} - ${job.name} / ${schedule.name} ${formatFilenameDate(timestamp)}`,
|
|
329
393
|
})
|
|
394
|
+
await resetVmOtherConfig(xapi, targetVmRef)
|
|
330
395
|
})
|
|
331
396
|
|
|
332
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.
|
|
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.
|
|
35
|
-
"@xen-orchestra/fs": "^4.
|
|
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.
|
|
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
|
}
|