@xen-orchestra/backups 0.69.3 → 0.70.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 +9 -1
- package/_incrementalVm.mjs +114 -34
- package/_otherConfig.mjs +26 -0
- package/_runners/_vmRunners/IncrementalXapi.mjs +14 -10
- package/_runners/_vmRunners/_AbstractXapi.mjs +20 -4
- package/_runners/_writers/IncrementalXapiWriter.mjs +57 -25
- package/_runners/_writers/_listReplicatedVms.mjs +4 -3
- package/disks/MergeRemoteDisk.mjs +8 -7
- package/disks/index.mjs +49 -0
- package/package.json +12 -8
package/ImportVmBackup.mjs
CHANGED
|
@@ -255,6 +255,14 @@ export class ImportVmBackup {
|
|
|
255
255
|
: await importIncrementalVm(backup, await xapi.getRecord('SR', srRef), {
|
|
256
256
|
newMacAddresses,
|
|
257
257
|
})
|
|
258
|
+
let size = 0
|
|
259
|
+
if (isFull) {
|
|
260
|
+
size = sizeContainer.size
|
|
261
|
+
} else {
|
|
262
|
+
for (const disk of Object.values(backup.disks)) {
|
|
263
|
+
size += disk.getNbGeneratedBlock() * disk.getBlockSize()
|
|
264
|
+
}
|
|
265
|
+
}
|
|
258
266
|
const remoteName = adapter._handler._remote.name
|
|
259
267
|
let desc = `Restored on ${formatFilenameDate(+new Date())}`
|
|
260
268
|
if (remoteName !== undefined) {
|
|
@@ -275,7 +283,7 @@ export class ImportVmBackup {
|
|
|
275
283
|
])
|
|
276
284
|
|
|
277
285
|
return {
|
|
278
|
-
size
|
|
286
|
+
size,
|
|
279
287
|
id: await xapi.getField('VM', vmRef, 'uuid'),
|
|
280
288
|
}
|
|
281
289
|
}
|
package/_incrementalVm.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { defer } from 'golike-defer'
|
|
|
8
8
|
import { cancelableMap } from './_cancelableMap.mjs'
|
|
9
9
|
import { Task } from './Task.mjs'
|
|
10
10
|
import pick from 'lodash/pick.js'
|
|
11
|
-
import { BASE_DELTA_VDI, COPY_OF, VM_UUID } from './_otherConfig.mjs'
|
|
11
|
+
import { BASE_DELTA_VDI, CONTENT_KEY, COPY_OF, VM_UUID } from './_otherConfig.mjs'
|
|
12
12
|
|
|
13
13
|
import { VHD_MAX_SIZE, XapiDiskSource } from '@xen-orchestra/xapi'
|
|
14
14
|
import { toVhdStream } from 'vhd-lib/disk-consumer/index.mjs'
|
|
@@ -124,7 +124,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
|
|
124
124
|
$defer,
|
|
125
125
|
incrementalVm,
|
|
126
126
|
sr,
|
|
127
|
-
{ cancelToken = CancelToken.none, newMacAddresses = false } = {}
|
|
127
|
+
{ cancelToken = CancelToken.none, newMacAddresses = false, targetRef = undefined } = {}
|
|
128
128
|
) {
|
|
129
129
|
const { version } = incrementalVm
|
|
130
130
|
if (compareVersions(version, '1.0.0') < 0) {
|
|
@@ -136,44 +136,106 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
|
|
136
136
|
|
|
137
137
|
const vdiRecords = incrementalVm.vdis
|
|
138
138
|
|
|
139
|
-
//
|
|
139
|
+
// When targetRef is provided, update the existing VM instead of creating a new one.
|
|
140
|
+
const targetVm = targetRef !== undefined ? xapi.getObjectByRef(targetRef, undefined) : undefined
|
|
141
|
+
const isUpdate = targetVm !== undefined && !targetVm.is_a_snapshot && !targetVm.is_a_template
|
|
142
|
+
|
|
143
|
+
// 0. Create suspend_VDI (only when creating a new VM).
|
|
140
144
|
let suspendVdi
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
145
|
+
if (!isUpdate) {
|
|
146
|
+
if (vmRecord.suspend_VDI !== undefined && vmRecord.suspend_VDI !== 'OpaqueRef:NULL') {
|
|
147
|
+
const vdi = vdiRecords[vmRecord.suspend_VDI]
|
|
148
|
+
if (vdi === undefined) {
|
|
149
|
+
Task.warning('Suspend VDI not available for this suspended VM', {
|
|
150
|
+
vm: pick(vmRecord, 'uuid', 'name_label', 'suspend_VDI'),
|
|
151
|
+
})
|
|
152
|
+
} else {
|
|
153
|
+
suspendVdi = await xapi.getRecord('VDI', await xapi.VDI_create(vdi))
|
|
154
|
+
$defer.onFailure(() => suspendVdi.$destroy())
|
|
155
|
+
}
|
|
150
156
|
}
|
|
151
157
|
}
|
|
152
158
|
|
|
153
|
-
// 1. Create the VM.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
159
|
+
// 1. Create the VM or update the existing one.
|
|
160
|
+
let vmRef
|
|
161
|
+
if (isUpdate) {
|
|
162
|
+
vmRef = targetRef
|
|
163
|
+
await Promise.all([
|
|
164
|
+
xapi.setFields('VM', vmRef, {
|
|
165
|
+
actions_after_crash: vmRecord.actions_after_crash,
|
|
166
|
+
actions_after_reboot: vmRecord.actions_after_reboot,
|
|
167
|
+
actions_after_shutdown: vmRecord.actions_after_shutdown,
|
|
168
|
+
domain_type: vmRecord.domain_type,
|
|
169
|
+
ha_restart_priority: vmRecord.ha_restart_priority,
|
|
170
|
+
has_vendor_device: vmRecord.has_vendor_device,
|
|
171
|
+
HVM_boot_params: vmRecord.HVM_boot_params,
|
|
172
|
+
HVM_boot_policy: vmRecord.HVM_boot_policy,
|
|
173
|
+
HVM_shadow_multiplier: vmRecord.HVM_shadow_multiplier,
|
|
174
|
+
memory_dynamic_max: vmRecord.memory_dynamic_max,
|
|
175
|
+
memory_dynamic_min: vmRecord.memory_dynamic_min,
|
|
176
|
+
memory_static_max: vmRecord.memory_static_max,
|
|
177
|
+
memory_static_min: vmRecord.memory_static_min,
|
|
178
|
+
name_label: vmRecord.name_label,
|
|
179
|
+
name_description: vmRecord.name_description,
|
|
180
|
+
order: vmRecord.order,
|
|
181
|
+
other_config: vmRecord.other_config,
|
|
182
|
+
platform: vmRecord.platform,
|
|
183
|
+
PV_args: vmRecord.PV_args,
|
|
184
|
+
PV_bootloader: vmRecord.PV_bootloader,
|
|
185
|
+
PV_bootloader_args: vmRecord.PV_bootloader_args,
|
|
186
|
+
PV_kernel: vmRecord.PV_kernel,
|
|
187
|
+
PV_legacy_args: vmRecord.PV_legacy_args,
|
|
188
|
+
PV_ramdisk: vmRecord.PV_ramdisk,
|
|
189
|
+
recommendations: vmRecord.recommendations,
|
|
190
|
+
shutdown_delay: vmRecord.shutdown_delay,
|
|
191
|
+
start_delay: vmRecord.start_delay,
|
|
192
|
+
suspend_SR: vmRecord.suspend_SR,
|
|
193
|
+
tags: vmRecord.tags,
|
|
194
|
+
user_version: vmRecord.user_version,
|
|
195
|
+
VCPUs_at_startup: vmRecord.VCPUs_at_startup,
|
|
196
|
+
VCPUs_max: vmRecord.VCPUs_max,
|
|
197
|
+
VCPUs_params: vmRecord.VCPUs_params,
|
|
198
|
+
xenstore_data: vmRecord.xenstore_data,
|
|
199
|
+
}),
|
|
200
|
+
targetVm.update_blocked_operations({
|
|
160
201
|
start: 'Importing…',
|
|
161
202
|
start_on: 'Importing…',
|
|
203
|
+
}),
|
|
204
|
+
])
|
|
205
|
+
} else {
|
|
206
|
+
vmRef = await xapi.VM_create(
|
|
207
|
+
{
|
|
208
|
+
...vmRecord,
|
|
209
|
+
affinity: undefined,
|
|
210
|
+
blocked_operations: {
|
|
211
|
+
...vmRecord.blocked_operations,
|
|
212
|
+
start: 'Importing…',
|
|
213
|
+
start_on: 'Importing…',
|
|
214
|
+
},
|
|
215
|
+
ha_always_run: false,
|
|
216
|
+
is_a_template: false,
|
|
217
|
+
name_label: '[Importing…] ' + vmRecord.name_label,
|
|
162
218
|
},
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
)
|
|
173
|
-
$defer.onFailure.call(xapi, 'VM_destroy', vmRef)
|
|
219
|
+
{
|
|
220
|
+
bios_strings: vmRecord.bios_strings,
|
|
221
|
+
generateMacSeed: newMacAddresses,
|
|
222
|
+
suspend_VDI: suspendVdi?.$ref,
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
$defer.onFailure.call(xapi, 'VM_destroy', vmRef)
|
|
226
|
+
}
|
|
174
227
|
|
|
175
228
|
// 2. Delete all VBDs which may have been created by the import.
|
|
176
|
-
|
|
229
|
+
// In update mode, also collect the VDI refs so orphaned VDIs can be destroyed after new ones are created.
|
|
230
|
+
const existingVbdRefs = await xapi.getField('VM', vmRef, 'VBDs')
|
|
231
|
+
const oldVdiRefs = new Set(
|
|
232
|
+
isUpdate
|
|
233
|
+
? (await asyncMap(existingVbdRefs, ref => xapi.getField('VBD', ref, 'VDI'))).filter(
|
|
234
|
+
ref => ref !== undefined && ref !== 'OpaqueRef:NULL'
|
|
235
|
+
)
|
|
236
|
+
: []
|
|
237
|
+
)
|
|
238
|
+
await asyncMap(existingVbdRefs, ref => ignoreErrors.call(xapi.call('VBD.destroy', ref)))
|
|
177
239
|
|
|
178
240
|
// 3. Create VDIs & VBDs.
|
|
179
241
|
const vbdRecords = incrementalVm.vbds
|
|
@@ -184,10 +246,18 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
|
|
184
246
|
let newVdi
|
|
185
247
|
|
|
186
248
|
if (vdi.baseVdi?.$ref !== undefined) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
249
|
+
if (isUpdate) {
|
|
250
|
+
// In update mode, reuse the existing target VDI directly — no clone needed.
|
|
251
|
+
newVdi = vdi.baseVdi
|
|
252
|
+
oldVdiRefs.delete(newVdi.$ref)
|
|
253
|
+
} else {
|
|
254
|
+
newVdi = await xapi.getRecord('VDI', await xapi.VDI_clone(vdi.baseVdi.$ref))
|
|
255
|
+
$defer.onFailure(() => newVdi.$destroy())
|
|
256
|
+
}
|
|
190
257
|
await newVdi.update_other_config(COPY_OF, vdi.uuid)
|
|
258
|
+
if (vdi.other_config[CONTENT_KEY] !== undefined) {
|
|
259
|
+
await newVdi.update_other_config(CONTENT_KEY, vdi.other_config[CONTENT_KEY])
|
|
260
|
+
}
|
|
191
261
|
if (vdi.virtual_size > newVdi.virtual_size) {
|
|
192
262
|
await newVdi.$callAsync('resize', vdi.virtual_size)
|
|
193
263
|
}
|
|
@@ -213,6 +283,16 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
|
|
213
283
|
newVdis[vdiRef] = newVdi
|
|
214
284
|
})
|
|
215
285
|
|
|
286
|
+
// 3.5. Destroy old VDIs that are no longer attached to the VM.
|
|
287
|
+
// Uses ignoreErrors because some storage backends refuse to destroy a VDI that still has snapshot children;
|
|
288
|
+
// those VDIs will become truly orphaned once the old snapshot VMs are cleaned up by _deleteOldEntries.
|
|
289
|
+
await asyncMap([...oldVdiRefs], ref => ignoreErrors.call(xapi.call('VDI.destroy', ref)))
|
|
290
|
+
|
|
291
|
+
// 4. For updates, destroy existing VIFs before recreating them.
|
|
292
|
+
if (isUpdate) {
|
|
293
|
+
await asyncMap(await xapi.getField('VM', vmRef, 'VIFs'), ref => ignoreErrors.call(xapi.call('VIF.destroy', ref)))
|
|
294
|
+
}
|
|
295
|
+
|
|
216
296
|
const networksByNameLabelByVlan = {}
|
|
217
297
|
let defaultNetwork
|
|
218
298
|
Object.values(xapi.objects.all).forEach(object => {
|
package/_otherConfig.mjs
CHANGED
|
@@ -28,6 +28,11 @@ export const EXPORTED_SUCCESSFULLY = 'xo:backup:exported'
|
|
|
28
28
|
// the VM ( not the snapshot) uuid
|
|
29
29
|
export const VM_UUID = 'xo:backup:vm'
|
|
30
30
|
|
|
31
|
+
// in `other_config` of source snapshot VDIs and replicated target VDIs
|
|
32
|
+
// contains the UUID of the source snapshot VDI whose content this VDI represents
|
|
33
|
+
// allows direction-agnostic discovery of common base VDIs (e.g. for failback/reverse replication)
|
|
34
|
+
export const CONTENT_KEY = 'xo:backup:contentKey'
|
|
35
|
+
|
|
31
36
|
async function listVdiRefs(xapi, vmRef) {
|
|
32
37
|
return xapi.VM_getDisks(vmRef)
|
|
33
38
|
}
|
|
@@ -131,3 +136,24 @@ export async function markExportSuccessfull(xapi, vmRef) {
|
|
|
131
136
|
xapi.setFieldEntry(type, ref, 'other_config', EXPORTED_SUCCESSFULLY, 'true')
|
|
132
137
|
)
|
|
133
138
|
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Set CONTENT_KEY on each VDI of a VM snapshot to its own UUID.
|
|
142
|
+
*
|
|
143
|
+
* This marks each snapshot VDI with a unique content identifier that can be
|
|
144
|
+
* propagated to replicated copies for direction-agnostic base VDI discovery
|
|
145
|
+
* (e.g. for failback / reverse replication).
|
|
146
|
+
*
|
|
147
|
+
* @param {Xapi} xapi
|
|
148
|
+
* @param {String} snapshotRef
|
|
149
|
+
* @returns {Promise}
|
|
150
|
+
*/
|
|
151
|
+
export async function setVmSnapshotContentKeys(xapi, snapshotRef) {
|
|
152
|
+
return applyToVmAndVdis(xapi, snapshotRef, async (type, ref) => {
|
|
153
|
+
if (type !== 'VDI') {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
const uuid = await xapi.getField(type, ref, 'uuid')
|
|
157
|
+
return xapi.setFieldEntry(type, ref, 'other_config', CONTENT_KEY, uuid)
|
|
158
|
+
})
|
|
159
|
+
}
|
|
@@ -135,16 +135,6 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
|
|
|
135
135
|
const deltaChainLength = Math.max(
|
|
136
136
|
...lastExportedVdis.map(({ other_config }) => Number(other_config[DELTA_CHAIN_LENGTH] ?? 0))
|
|
137
137
|
)
|
|
138
|
-
const fullInterval = this._settings.fullInterval
|
|
139
|
-
if (fullInterval !== 0 && fullInterval <= deltaChainLength + 1) {
|
|
140
|
-
debug('not using base VM because fullInterval reached', {
|
|
141
|
-
fullInterval,
|
|
142
|
-
deltaChainLength,
|
|
143
|
-
eq: fullInterval < deltaChainLength + 1,
|
|
144
|
-
dc1: deltaChainLength + 1,
|
|
145
|
-
})
|
|
146
|
-
return
|
|
147
|
-
}
|
|
148
138
|
|
|
149
139
|
const srcVdis = keyBy(await xapi.getRecords('VDI', await this._vm.$getDisks()), '$ref')
|
|
150
140
|
|
|
@@ -173,6 +163,20 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
|
|
|
173
163
|
debug('no base VM found')
|
|
174
164
|
return
|
|
175
165
|
}
|
|
166
|
+
|
|
167
|
+
// we do tafter checkbasevdis because we want the writer to know the target VM
|
|
168
|
+
// especially on replication when we alwayr update the same VM
|
|
169
|
+
const fullInterval = this._settings.fullInterval
|
|
170
|
+
if (fullInterval !== 0 && fullInterval <= deltaChainLength + 1) {
|
|
171
|
+
debug('not using base VM because fullInterval reached', {
|
|
172
|
+
fullInterval,
|
|
173
|
+
deltaChainLength,
|
|
174
|
+
eq: fullInterval < deltaChainLength + 1,
|
|
175
|
+
dc1: deltaChainLength + 1,
|
|
176
|
+
})
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
176
180
|
baseUuidToSrcVdiUuid.forEach((srcVdiUuid, baseUuid) => {
|
|
177
181
|
if (presentBaseVdis.has(baseUuid)) {
|
|
178
182
|
debug('found base VDI', {
|
|
@@ -10,7 +10,16 @@ import { defer } from 'golike-defer'
|
|
|
10
10
|
import { getOldEntries } from '../../_getOldEntries.mjs'
|
|
11
11
|
import { Task } from '../../Task.mjs'
|
|
12
12
|
import { Abstract } from './_Abstract.mjs'
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
COPY_OF,
|
|
15
|
+
DATETIME,
|
|
16
|
+
JOB_ID,
|
|
17
|
+
SCHEDULE_ID,
|
|
18
|
+
VM_UUID,
|
|
19
|
+
resetVmOtherConfig,
|
|
20
|
+
setVmOtherConfig,
|
|
21
|
+
setVmSnapshotContentKeys,
|
|
22
|
+
} from '../../_otherConfig.mjs'
|
|
14
23
|
|
|
15
24
|
const { warn, info } = createLogger('xo:backups:AbstractXapi')
|
|
16
25
|
|
|
@@ -205,6 +214,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
205
214
|
scheduleId: this.scheduleId,
|
|
206
215
|
vmUuid: vm.uuid,
|
|
207
216
|
})
|
|
217
|
+
await setVmSnapshotContentKeys(xapi, snapshotRef)
|
|
208
218
|
const snapshot = await xapi.getRecord('VM', snapshotRef)
|
|
209
219
|
await snapshot.set_name_label(this._getSnapshotNameLabel(vm))
|
|
210
220
|
// reload data to ensure it is up to date with the new name label
|
|
@@ -233,11 +243,16 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
233
243
|
const xapi = this._xapi
|
|
234
244
|
|
|
235
245
|
const vdiCandidates = {}
|
|
236
|
-
|
|
246
|
+
const vdiUuids = this._vm.$VBDs.map(({ VDI }) => VDI)
|
|
237
247
|
Object.values(xapi.objects.indexes.type.VDI)
|
|
238
248
|
.filter(_ => !!_) // filter nullish
|
|
239
|
-
.filter(({ other_config,
|
|
240
|
-
return
|
|
249
|
+
.filter(({ other_config, snapshot_of }) => {
|
|
250
|
+
return (
|
|
251
|
+
vdiUuids.includes(snapshot_of) &&
|
|
252
|
+
other_config[JOB_ID] === jobId &&
|
|
253
|
+
other_config[VM_UUID] === this._vm.uuid &&
|
|
254
|
+
other_config[COPY_OF] === undefined
|
|
255
|
+
)
|
|
241
256
|
})
|
|
242
257
|
.forEach(vdi => {
|
|
243
258
|
vdiCandidates[vdi.uuid] = vdi
|
|
@@ -310,6 +325,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
310
325
|
// if not => remove it from the list to ensure we won't half destroy VM later
|
|
311
326
|
vm.$VBDs
|
|
312
327
|
.filter(({ $VDI }) => !!$VDI) // filter missing keys
|
|
328
|
+
.filter(({ $VDI }) => $VDI.$snapshot_of !== undefined) // skip non-snapshot VDIs (e.g., ISOs/CD-ROMs)
|
|
313
329
|
.filter(({ $VDI }) => $VDI && vdiCandidates[$VDI.uuid] === undefined)
|
|
314
330
|
.forEach(({ $VDI: outOfSnapshotsVdi, ...other }) => {
|
|
315
331
|
warn(
|
|
@@ -7,7 +7,7 @@ import { Task } from '../../Task.mjs'
|
|
|
7
7
|
|
|
8
8
|
import { AbstractIncrementalWriter } from './_AbstractIncrementalWriter.mjs'
|
|
9
9
|
import { MixinXapiWriter } from './_MixinXapiWriter.mjs'
|
|
10
|
-
import { listReplicatedVms } from './_listReplicatedVms.mjs'
|
|
10
|
+
import { compareReplicatedVmDatetime, listReplicatedVms } from './_listReplicatedVms.mjs'
|
|
11
11
|
import {
|
|
12
12
|
COPY_OF,
|
|
13
13
|
setVmOtherConfig,
|
|
@@ -30,25 +30,38 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
30
30
|
return
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
return (
|
|
46
|
+
vm.other_config[JOB_ID] === this._job.id &&
|
|
47
|
+
vm.other_config[VM_UUID] === this._vmUuid &&
|
|
48
|
+
'start' in vm.blocked_operations
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const replicatedCopyOfUuids = replicatedVdis.map(({ other_config }) => other_config?.[COPY_OF]).filter(_ => !!_)
|
|
46
53
|
|
|
47
54
|
for (const uuid of baseUuidToSrcVdi.keys()) {
|
|
48
|
-
if (!
|
|
55
|
+
if (!replicatedCopyOfUuids.includes(uuid)) {
|
|
49
56
|
baseUuidToSrcVdi.delete(uuid)
|
|
50
57
|
}
|
|
51
58
|
}
|
|
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
|
+
}
|
|
52
65
|
}
|
|
53
66
|
updateUuidAndChain() {
|
|
54
67
|
// nothing to do, the chaining is not modified in this case
|
|
@@ -81,7 +94,16 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
81
94
|
// delete previous interrupted copies
|
|
82
95
|
ignoreErrors.call(asyncMapSettled(listReplicatedVms(xapi, scheduleId, undefined, vmUuid), vm => vm.$destroy))
|
|
83
96
|
|
|
84
|
-
|
|
97
|
+
const allEntries = listReplicatedVms(xapi, scheduleId, srUuid, vmUuid)
|
|
98
|
+
|
|
99
|
+
// In the snapshot-based flow a non-snapshot VM (the live target) coexists with its
|
|
100
|
+
// snapshots (one per transfer). That VM must not be subject to retention — only its
|
|
101
|
+
// snapshots are. Build the set of VM refs that already have snapshots in the list so
|
|
102
|
+
// we can exclude them, while keeping old-style non-snapshot VMs (no snapshots).
|
|
103
|
+
const vmRefsWithSnapshots = new Set(allEntries.filter(e => e.is_a_snapshot).map(e => e.snapshot_of))
|
|
104
|
+
const retentionEntries = allEntries.filter(e => e.is_a_snapshot || !vmRefsWithSnapshots.has(e.$ref))
|
|
105
|
+
retentionEntries.sort(compareReplicatedVmDatetime)
|
|
106
|
+
this._oldEntries = getOldEntries(settings.copyRetention - 1, retentionEntries)
|
|
85
107
|
|
|
86
108
|
if (settings.deleteFirst && settings.skipDeleteOldEntries) {
|
|
87
109
|
// we want to keep the baseVM when copying a delta
|
|
@@ -112,7 +134,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
112
134
|
const job = this._job
|
|
113
135
|
const scheduleId = this._scheduleId
|
|
114
136
|
|
|
115
|
-
vm.name_label = `${vm.name_label} - ${job.name}
|
|
137
|
+
vm.name_label = `${vm.name_label} - ${job.name}`
|
|
116
138
|
// update other_config data as soon as possible to ensure the next job
|
|
117
139
|
// will be able to detect any partial transfer and lean them
|
|
118
140
|
vm.other_config[COPY_OF] = vm.uuid
|
|
@@ -180,7 +202,24 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
180
202
|
|
|
181
203
|
let targetVmRef
|
|
182
204
|
await Task.run({ name: 'transfer' }, async () => {
|
|
183
|
-
targetVmRef = await importIncrementalVm(this.#decorateVmMetadata(deltaExport, timestamp), sr
|
|
205
|
+
targetVmRef = await importIncrementalVm(this.#decorateVmMetadata(deltaExport, timestamp), sr, {
|
|
206
|
+
targetRef: this._targetVmRef,
|
|
207
|
+
})
|
|
208
|
+
// this also ensure the data are up to date on the snapshot
|
|
209
|
+
await setVmOtherConfig(xapi, targetVmRef, {
|
|
210
|
+
timestamp, // updated at the end to mark the transfer as complete
|
|
211
|
+
jobId: job.id,
|
|
212
|
+
scheduleId,
|
|
213
|
+
vmUuid: vm.uuid,
|
|
214
|
+
srUuid,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// 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)})`,
|
|
221
|
+
})
|
|
222
|
+
)
|
|
184
223
|
// size is mandatory to ensure the task have the right data
|
|
185
224
|
return {
|
|
186
225
|
size: Object.values(deltaExport.disks).reduce(
|
|
@@ -197,13 +236,6 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
197
236
|
!_warmMigration &&
|
|
198
237
|
targetVm.ha_restart_priority !== '' &&
|
|
199
238
|
Promise.all([targetVm.set_ha_restart_priority(''), targetVm.add_tags('HA disabled')]),
|
|
200
|
-
setVmOtherConfig(xapi, targetVmRef, {
|
|
201
|
-
timestamp, // updated at the end to mark the transfer as complete
|
|
202
|
-
jobId: job.id,
|
|
203
|
-
scheduleId,
|
|
204
|
-
vmUuid: vm.uuid,
|
|
205
|
-
srUuid,
|
|
206
|
-
}),
|
|
207
239
|
])
|
|
208
240
|
}
|
|
209
241
|
}
|
|
@@ -23,12 +23,13 @@ export function listReplicatedVms(xapi, scheduleOrJobId, srUuid, vmUuid) {
|
|
|
23
23
|
const oc = object.other_config
|
|
24
24
|
if (
|
|
25
25
|
object.$type === 'VM' &&
|
|
26
|
-
!object.is_a_snapshot &&
|
|
27
26
|
!object.is_a_template &&
|
|
28
|
-
'start' in object.blocked_operations &&
|
|
29
27
|
(oc[JOB_ID] === scheduleOrJobId || oc[SCHEDULE_ID] === scheduleOrJobId) &&
|
|
30
28
|
oc[REPLICATED_TO_SR_UUID] === srUuid &&
|
|
31
|
-
oc[VM_UUID] === vmUuid
|
|
29
|
+
oc[VM_UUID] === vmUuid &&
|
|
30
|
+
// Old-style replication: one VM per transfer (non-snapshot, start blocked)
|
|
31
|
+
// New-style replication: snapshots of the target VM represent each transfer
|
|
32
|
+
(!object.is_a_snapshot ? 'start' in object.blocked_operations : true)
|
|
32
33
|
) {
|
|
33
34
|
vms[object.$id] = object
|
|
34
35
|
}
|
|
@@ -22,6 +22,7 @@ const { warn } = createLogger('remote-disk:merge')
|
|
|
22
22
|
* @property {number} mergedDataSize
|
|
23
23
|
* @property {'mergeBlocks' | 'cleanup'} step
|
|
24
24
|
* @property {number} diskSize
|
|
25
|
+
* @typedef {(message: string, data?: Record<string, unknown>) => void} Logger
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
28
|
export class MergeRemoteDisk {
|
|
@@ -85,11 +86,11 @@ export class MergeRemoteDisk {
|
|
|
85
86
|
/**
|
|
86
87
|
* @param {FileAccessor} handler
|
|
87
88
|
* @param {Object} params
|
|
88
|
-
* @param {Function} params.onProgress
|
|
89
|
-
* @param {Logger | Function} params.logInfo
|
|
90
|
-
* @param {boolean} params.removeUnused
|
|
91
|
-
* @param {number} params.mergeBlockConcurrency
|
|
92
|
-
* @param {number} params.writeStateDelay
|
|
89
|
+
* @param {Function} [params.onProgress]
|
|
90
|
+
* @param {Logger | Function} [params.logInfo]
|
|
91
|
+
* @param {boolean} [params.removeUnused]
|
|
92
|
+
* @param {number} [params.mergeBlockConcurrency]
|
|
93
|
+
* @param {number} [params.writeStateDelay]
|
|
93
94
|
*/
|
|
94
95
|
constructor(
|
|
95
96
|
handler,
|
|
@@ -202,8 +203,8 @@ export class MergeRemoteDisk {
|
|
|
202
203
|
|
|
203
204
|
parentDisk.setAllocatedBlocks(alreadyMergedBlocks)
|
|
204
205
|
} else {
|
|
205
|
-
this.#state.child = { uuid: childDisk.getUuid() ??
|
|
206
|
-
this.#state.parent = { uuid: parentDisk.getUuid() ??
|
|
206
|
+
this.#state.child = { uuid: childDisk.getUuid() ?? undefined }
|
|
207
|
+
this.#state.parent = { uuid: parentDisk.getUuid() ?? undefined }
|
|
207
208
|
|
|
208
209
|
// Finds first allocated block for the 2 following loops
|
|
209
210
|
while (this.#state.currentBlock < getMaxBlockCount && !childDisk.hasBlock(this.#state.currentBlock)) {
|
package/disks/index.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { RemoteVhdDisk } from './RemoteVhdDisk.mjs'
|
|
2
|
+
|
|
3
|
+
export { RemoteDisk } from './RemoteDisk.mjs'
|
|
4
|
+
export { openDiskChain } from './openDiskChain.mjs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {import('../../disk-transform/src/FileAccessor.mjs').FileAccessor} FileAccessor
|
|
8
|
+
* @typedef {import('./RemoteDisk.mjs').RemoteDisk} RemoteDisk
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} params
|
|
13
|
+
* @param {FileAccessor} params.handler
|
|
14
|
+
* @param {string} params.path
|
|
15
|
+
* @returns {Promise<RemoteDisk>}
|
|
16
|
+
*/
|
|
17
|
+
export async function openDisk({ handler, path }) {
|
|
18
|
+
const disk = new RemoteVhdDisk({ handler, path })
|
|
19
|
+
await disk.init()
|
|
20
|
+
return disk
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} params
|
|
25
|
+
* @param {FileAccessor} params.handler
|
|
26
|
+
* @param {string} params.path
|
|
27
|
+
* @returns {Promise<Disposable<RemoteDisk>>}
|
|
28
|
+
*/
|
|
29
|
+
export async function openDisposableDisk({ handler, path }) {
|
|
30
|
+
const disk = new RemoteVhdDisk({ handler, path })
|
|
31
|
+
await disk.init()
|
|
32
|
+
return {
|
|
33
|
+
value: disk,
|
|
34
|
+
dispose: () => disk.close(),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DISK_EXTENSIONS = ['.vhd']
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Returns true if the path points to a supported disk format.
|
|
42
|
+
*
|
|
43
|
+
* @param {FileAccessor} _handler - Remote file handler (reserved for future use)
|
|
44
|
+
* @param {string} path - Path to check
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
export function isDisk(_handler, path) {
|
|
48
|
+
return DISK_EXTENSIONS.some(ext => path.endsWith(ext))
|
|
49
|
+
}
|
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.70.0",
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=14.18"
|
|
14
14
|
},
|
|
@@ -19,20 +19,20 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@iarna/toml": "^2.2.5",
|
|
21
21
|
"@kldzj/stream-throttle": "^1.1.1",
|
|
22
|
-
"@vates/async-each": "^1.0.
|
|
22
|
+
"@vates/async-each": "^1.0.2",
|
|
23
23
|
"@vates/cached-dns.lookup": "^1.0.0",
|
|
24
24
|
"@vates/compose": "^2.1.0",
|
|
25
25
|
"@vates/decorate-with": "^2.1.0",
|
|
26
26
|
"@vates/disposable": "^0.1.6",
|
|
27
27
|
"@vates/fuse-vhd": "^2.1.2",
|
|
28
|
-
"@vates/generator-toolbox": "^1.1.
|
|
29
|
-
"@vates/nbd-client": "^3.
|
|
28
|
+
"@vates/generator-toolbox": "^1.1.1",
|
|
29
|
+
"@vates/nbd-client": "^3.3.0",
|
|
30
30
|
"@vates/parse-duration": "^0.1.1",
|
|
31
31
|
"@xen-orchestra/async-map": "^0.1.2",
|
|
32
|
-
"@xen-orchestra/disk-transform": "^1.2.
|
|
33
|
-
"@xen-orchestra/fs": "^4.
|
|
32
|
+
"@xen-orchestra/disk-transform": "^1.2.2",
|
|
33
|
+
"@xen-orchestra/fs": "^4.7.0",
|
|
34
34
|
"@xen-orchestra/log": "^0.7.1",
|
|
35
|
-
"@xen-orchestra/qcow2": "^1.
|
|
35
|
+
"@xen-orchestra/qcow2": "^1.2.0",
|
|
36
36
|
"@xen-orchestra/template": "^0.1.0",
|
|
37
37
|
"app-conf": "^3.0.0",
|
|
38
38
|
"compare-versions": "^6.0.0",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"tar": "^7.5.3",
|
|
52
52
|
"uuid": "^9.0.0",
|
|
53
53
|
"value-matcher": "^0.2.0",
|
|
54
|
-
"vhd-lib": "^4.
|
|
54
|
+
"vhd-lib": "^4.15.0",
|
|
55
55
|
"xen-api": "^4.7.6",
|
|
56
56
|
"yazl": "^2.5.1"
|
|
57
57
|
},
|
|
@@ -68,5 +68,9 @@
|
|
|
68
68
|
"author": {
|
|
69
69
|
"name": "Vates SAS",
|
|
70
70
|
"url": "https://vates.fr"
|
|
71
|
+
},
|
|
72
|
+
"exports": {
|
|
73
|
+
"./disks": "./disks/index.mjs",
|
|
74
|
+
"./*": "./*"
|
|
71
75
|
}
|
|
72
76
|
}
|