@xen-orchestra/backups 0.27.3 → 0.28.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/Backup.js +1 -1
- package/RemoteAdapter.js +141 -53
- package/Task.js +4 -2
- package/_VmBackup.js +22 -15
- package/_cleanVm.js +55 -45
- package/_deltaVm.js +1 -1
- package/_forkStreamUnpipe.js +9 -2
- package/_isValidXva.js +5 -1
- package/package.json +8 -6
- package/writers/DeltaBackupWriter.js +3 -35
- package/writers/FullBackupWriter.js +1 -4
- package/writers/_MixinBackupWriter.js +40 -3
package/Backup.js
CHANGED
|
@@ -245,7 +245,7 @@ exports.Backup = class Backup {
|
|
|
245
245
|
})
|
|
246
246
|
)
|
|
247
247
|
),
|
|
248
|
-
() => settings.healthCheckSr !== undefined ? this._getRecord('SR', settings.healthCheckSr) : undefined,
|
|
248
|
+
() => (settings.healthCheckSr !== undefined ? this._getRecord('SR', settings.healthCheckSr) : undefined),
|
|
249
249
|
async (srs, remoteAdapters, healthCheckSr) => {
|
|
250
250
|
// remove adapters that failed (already handled)
|
|
251
251
|
remoteAdapters = remoteAdapters.filter(_ => _ !== undefined)
|
package/RemoteAdapter.js
CHANGED
|
@@ -22,11 +22,15 @@ const zlib = require('zlib')
|
|
|
22
22
|
|
|
23
23
|
const { BACKUP_DIR } = require('./_getVmBackupDir.js')
|
|
24
24
|
const { cleanVm } = require('./_cleanVm.js')
|
|
25
|
+
const { formatFilenameDate } = require('./_filenameDate.js')
|
|
25
26
|
const { getTmpDir } = require('./_getTmpDir.js')
|
|
26
27
|
const { isMetadataFile } = require('./_backupType.js')
|
|
27
28
|
const { isValidXva } = require('./_isValidXva.js')
|
|
28
29
|
const { listPartitions, LVM_PARTITION_TYPE } = require('./_listPartitions.js')
|
|
29
30
|
const { lvs, pvs } = require('./_lvm.js')
|
|
31
|
+
// @todo : this import is marked extraneous , sould be fixed when lib is published
|
|
32
|
+
const { mount } = require('@vates/fuse-vhd')
|
|
33
|
+
const { asyncEach } = require('@vates/async-each')
|
|
30
34
|
|
|
31
35
|
const DIR_XO_CONFIG_BACKUPS = 'xo-config-backups'
|
|
32
36
|
exports.DIR_XO_CONFIG_BACKUPS = DIR_XO_CONFIG_BACKUPS
|
|
@@ -34,7 +38,7 @@ exports.DIR_XO_CONFIG_BACKUPS = DIR_XO_CONFIG_BACKUPS
|
|
|
34
38
|
const DIR_XO_POOL_METADATA_BACKUPS = 'xo-pool-metadata-backups'
|
|
35
39
|
exports.DIR_XO_POOL_METADATA_BACKUPS = DIR_XO_POOL_METADATA_BACKUPS
|
|
36
40
|
|
|
37
|
-
const { warn } = createLogger('xo:backups:RemoteAdapter')
|
|
41
|
+
const { debug, warn } = createLogger('xo:backups:RemoteAdapter')
|
|
38
42
|
|
|
39
43
|
const compareTimestamp = (a, b) => a.timestamp - b.timestamp
|
|
40
44
|
|
|
@@ -44,8 +48,6 @@ const resolveRelativeFromFile = (file, path) => resolve('/', dirname(file), path
|
|
|
44
48
|
|
|
45
49
|
const resolveSubpath = (root, path) => resolve(root, `.${resolve('/', path)}`)
|
|
46
50
|
|
|
47
|
-
const RE_VHDI = /^vhdi(\d+)$/
|
|
48
|
-
|
|
49
51
|
async function addDirectory(files, realPath, metadataPath) {
|
|
50
52
|
const stats = await lstat(realPath)
|
|
51
53
|
if (stats.isDirectory()) {
|
|
@@ -74,12 +76,14 @@ const debounceResourceFactory = factory =>
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
class RemoteAdapter {
|
|
77
|
-
constructor(handler, { debounceResource = res => res, dirMode, vhdDirectoryCompression } = {}) {
|
|
79
|
+
constructor(handler, { debounceResource = res => res, dirMode, vhdDirectoryCompression, useGetDiskLegacy=false } = {}) {
|
|
78
80
|
this._debounceResource = debounceResource
|
|
79
81
|
this._dirMode = dirMode
|
|
80
82
|
this._handler = handler
|
|
81
83
|
this._vhdDirectoryCompression = vhdDirectoryCompression
|
|
82
84
|
this._readCacheListVmBackups = synchronized.withKey()(this._readCacheListVmBackups)
|
|
85
|
+
this._useGetDiskLegacy = useGetDiskLegacy
|
|
86
|
+
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
get handler() {
|
|
@@ -127,7 +131,9 @@ class RemoteAdapter {
|
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
async *_getPartition(devicePath, partition) {
|
|
130
|
-
|
|
134
|
+
// the norecovery option is necessary because if the partition is dirty,
|
|
135
|
+
// mount will try to fix it which is impossible if because the device is read-only
|
|
136
|
+
const options = ['loop', 'ro', 'norecovery']
|
|
131
137
|
|
|
132
138
|
if (partition !== undefined) {
|
|
133
139
|
const { size, start } = partition
|
|
@@ -224,11 +230,30 @@ class RemoteAdapter {
|
|
|
224
230
|
return promise
|
|
225
231
|
}
|
|
226
232
|
|
|
233
|
+
#removeVmBackupsFromCache(backups) {
|
|
234
|
+
for (const [dir, filenames] of Object.entries(
|
|
235
|
+
groupBy(
|
|
236
|
+
backups.map(_ => _._filename),
|
|
237
|
+
dirname
|
|
238
|
+
)
|
|
239
|
+
)) {
|
|
240
|
+
// detached async action, will not reject
|
|
241
|
+
this._updateCache(dir + '/cache.json.gz', backups => {
|
|
242
|
+
for (const filename of filenames) {
|
|
243
|
+
debug('removing cache entry', { entry: filename })
|
|
244
|
+
delete backups[filename]
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
227
250
|
async deleteDeltaVmBackups(backups) {
|
|
228
251
|
const handler = this._handler
|
|
229
252
|
|
|
230
253
|
// this will delete the json, unused VHDs will be detected by `cleanVm`
|
|
231
254
|
await asyncMapSettled(backups, ({ _filename }) => handler.unlink(_filename))
|
|
255
|
+
|
|
256
|
+
this.#removeVmBackupsFromCache(backups)
|
|
232
257
|
}
|
|
233
258
|
|
|
234
259
|
async deleteMetadataBackup(backupId) {
|
|
@@ -256,6 +281,8 @@ class RemoteAdapter {
|
|
|
256
281
|
await asyncMapSettled(backups, ({ _filename, xva }) =>
|
|
257
282
|
Promise.all([handler.unlink(_filename), handler.unlink(resolveRelativeFromFile(_filename, xva))])
|
|
258
283
|
)
|
|
284
|
+
|
|
285
|
+
this.#removeVmBackupsFromCache(backups)
|
|
259
286
|
}
|
|
260
287
|
|
|
261
288
|
deleteVmBackup(file) {
|
|
@@ -276,14 +303,13 @@ class RemoteAdapter {
|
|
|
276
303
|
full !== undefined && this.deleteFullVmBackups(full),
|
|
277
304
|
])
|
|
278
305
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
// don't
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
await asyncMap(dedupedVmUuid, vmUuid => this.invalidateVmBackupListCache(vmUuid))
|
|
306
|
+
await asyncMap(new Set(files.map(file => dirname(file))), dir =>
|
|
307
|
+
// - don't merge in main process, unused VHDs will be merged in the next backup run
|
|
308
|
+
// - don't error in case this fails:
|
|
309
|
+
// - if lock is already being held, a backup is running and cleanVm will be ran at the end
|
|
310
|
+
// - otherwise, there is nothing more we can do, orphan file will be cleaned in the future
|
|
311
|
+
this.cleanVm(dir, { remove: true, logWarn: warn }).catch(noop)
|
|
312
|
+
)
|
|
287
313
|
}
|
|
288
314
|
|
|
289
315
|
#getCompressionType() {
|
|
@@ -298,7 +324,10 @@ class RemoteAdapter {
|
|
|
298
324
|
return this.#useVhdDirectory()
|
|
299
325
|
}
|
|
300
326
|
|
|
301
|
-
|
|
327
|
+
|
|
328
|
+
async *#getDiskLegacy(diskId) {
|
|
329
|
+
|
|
330
|
+
const RE_VHDI = /^vhdi(\d+)$/
|
|
302
331
|
const handler = this._handler
|
|
303
332
|
|
|
304
333
|
const diskPath = handler._getFilePath('/' + diskId)
|
|
@@ -328,6 +357,20 @@ class RemoteAdapter {
|
|
|
328
357
|
}
|
|
329
358
|
}
|
|
330
359
|
|
|
360
|
+
async *getDisk(diskId) {
|
|
361
|
+
if(this._useGetDiskLegacy){
|
|
362
|
+
yield * this.#getDiskLegacy(diskId)
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
const handler = this._handler
|
|
366
|
+
// this is a disposable
|
|
367
|
+
const mountDir = yield getTmpDir()
|
|
368
|
+
// this is also a disposable
|
|
369
|
+
yield mount(handler, diskId, mountDir)
|
|
370
|
+
// this will yield disk path to caller
|
|
371
|
+
yield `${mountDir}/vhd0`
|
|
372
|
+
}
|
|
373
|
+
|
|
331
374
|
// partitionId values:
|
|
332
375
|
//
|
|
333
376
|
// - undefined: raw disk
|
|
@@ -378,22 +421,25 @@ class RemoteAdapter {
|
|
|
378
421
|
listPartitionFiles(diskId, partitionId, path) {
|
|
379
422
|
return Disposable.use(this.getPartition(diskId, partitionId), async rootPath => {
|
|
380
423
|
path = resolveSubpath(rootPath, path)
|
|
381
|
-
|
|
382
424
|
const entriesMap = {}
|
|
383
|
-
await
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
425
|
+
await asyncEach(
|
|
426
|
+
await readdir(path),
|
|
427
|
+
async name => {
|
|
428
|
+
try {
|
|
429
|
+
const stats = await lstat(`${path}/${name}`)
|
|
430
|
+
if (stats.isDirectory()) {
|
|
431
|
+
entriesMap[name + '/'] = {}
|
|
432
|
+
} else if (stats.isFile()) {
|
|
433
|
+
entriesMap[name] = {}
|
|
434
|
+
}
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (error == null || error.code !== 'ENOENT') {
|
|
437
|
+
throw error
|
|
438
|
+
}
|
|
394
439
|
}
|
|
395
|
-
}
|
|
396
|
-
|
|
440
|
+
},
|
|
441
|
+
{ concurrency: 1 }
|
|
442
|
+
)
|
|
397
443
|
|
|
398
444
|
return entriesMap
|
|
399
445
|
})
|
|
@@ -458,11 +504,46 @@ class RemoteAdapter {
|
|
|
458
504
|
return backupsByPool
|
|
459
505
|
}
|
|
460
506
|
|
|
507
|
+
#getVmBackupsCache(vmUuid) {
|
|
508
|
+
return `${BACKUP_DIR}/${vmUuid}/cache.json.gz`
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async #readCache(path) {
|
|
512
|
+
try {
|
|
513
|
+
return JSON.parse(await fromCallback(zlib.gunzip, await this.handler.readFile(path)))
|
|
514
|
+
} catch (error) {
|
|
515
|
+
if (error.code !== 'ENOENT') {
|
|
516
|
+
warn('#readCache', { error, path })
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
_updateCache = synchronized.withKey()(this._updateCache)
|
|
522
|
+
// eslint-disable-next-line no-dupe-class-members
|
|
523
|
+
async _updateCache(path, fn) {
|
|
524
|
+
const cache = await this.#readCache(path)
|
|
525
|
+
if (cache !== undefined) {
|
|
526
|
+
fn(cache)
|
|
527
|
+
|
|
528
|
+
await this.#writeCache(path, cache)
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async #writeCache(path, data) {
|
|
533
|
+
try {
|
|
534
|
+
await this.handler.writeFile(path, await fromCallback(zlib.gzip, JSON.stringify(data)), { flags: 'w' })
|
|
535
|
+
} catch (error) {
|
|
536
|
+
warn('#writeCache', { error, path })
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
461
540
|
async invalidateVmBackupListCache(vmUuid) {
|
|
462
|
-
await this.handler.unlink(
|
|
541
|
+
await this.handler.unlink(this.#getVmBackupsCache(vmUuid))
|
|
463
542
|
}
|
|
464
543
|
|
|
465
544
|
async #getCachabledDataListVmBackups(dir) {
|
|
545
|
+
debug('generating cache', { path: dir })
|
|
546
|
+
|
|
466
547
|
const handler = this._handler
|
|
467
548
|
const backups = {}
|
|
468
549
|
|
|
@@ -498,41 +579,26 @@ class RemoteAdapter {
|
|
|
498
579
|
// if cache is missing or broken => regenerate it and return
|
|
499
580
|
|
|
500
581
|
async _readCacheListVmBackups(vmUuid) {
|
|
501
|
-
const
|
|
502
|
-
const path = `${dir}/cache.json.gz`
|
|
582
|
+
const path = this.#getVmBackupsCache(vmUuid)
|
|
503
583
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
return
|
|
508
|
-
} catch (error) {
|
|
509
|
-
if (error.code !== 'ENOENT') {
|
|
510
|
-
warn('Cache file was unreadable', { vmUuid, error })
|
|
511
|
-
}
|
|
584
|
+
const cache = await this.#readCache(path)
|
|
585
|
+
if (cache !== undefined) {
|
|
586
|
+
debug('found VM backups cache, using it', { path })
|
|
587
|
+
return cache
|
|
512
588
|
}
|
|
513
589
|
|
|
514
590
|
// nothing cached, or cache unreadable => regenerate it
|
|
515
|
-
const backups = await this.#getCachabledDataListVmBackups(
|
|
591
|
+
const backups = await this.#getCachabledDataListVmBackups(`${BACKUP_DIR}/${vmUuid}`)
|
|
516
592
|
if (backups === undefined) {
|
|
517
593
|
return
|
|
518
594
|
}
|
|
519
595
|
|
|
520
596
|
// detached async action, will not reject
|
|
521
|
-
this.#
|
|
597
|
+
this.#writeCache(path, backups)
|
|
522
598
|
|
|
523
599
|
return backups
|
|
524
600
|
}
|
|
525
601
|
|
|
526
|
-
async #writeVmBackupsCache(cacheFile, backups) {
|
|
527
|
-
try {
|
|
528
|
-
const text = JSON.stringify(backups)
|
|
529
|
-
const zipped = await fromCallback(zlib.gzip, text)
|
|
530
|
-
await this.handler.writeFile(cacheFile, zipped, { flags: 'w' })
|
|
531
|
-
} catch (error) {
|
|
532
|
-
warn('writeVmBackupsCache', { cacheFile, error })
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
602
|
async listVmBackups(vmUuid, predicate) {
|
|
537
603
|
const backups = []
|
|
538
604
|
const cached = await this._readCacheListVmBackups(vmUuid)
|
|
@@ -571,13 +637,35 @@ class RemoteAdapter {
|
|
|
571
637
|
return backups.sort(compareTimestamp)
|
|
572
638
|
}
|
|
573
639
|
|
|
574
|
-
async
|
|
640
|
+
async writeVmBackupMetadata(vmUuid, metadata) {
|
|
641
|
+
const path = `/${BACKUP_DIR}/${vmUuid}/${formatFilenameDate(metadata.timestamp)}.json`
|
|
642
|
+
|
|
643
|
+
await this.handler.outputFile(path, JSON.stringify(metadata), {
|
|
644
|
+
dirMode: this._dirMode,
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
// will not throw
|
|
648
|
+
this._updateCache(this.#getVmBackupsCache(vmUuid), backups => {
|
|
649
|
+
debug('adding cache entry', { entry: path })
|
|
650
|
+
backups[path] = {
|
|
651
|
+
...metadata,
|
|
652
|
+
|
|
653
|
+
// these values are required in the cache
|
|
654
|
+
_filename: path,
|
|
655
|
+
id: path,
|
|
656
|
+
}
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
return path
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency } = {}) {
|
|
575
663
|
const handler = this._handler
|
|
576
664
|
|
|
577
665
|
if (this.#useVhdDirectory()) {
|
|
578
666
|
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
|
|
579
667
|
await createVhdDirectoryFromStream(handler, dataPath, input, {
|
|
580
|
-
concurrency:
|
|
668
|
+
concurrency: writeBlockConcurrency,
|
|
581
669
|
compression: this.#getCompressionType(),
|
|
582
670
|
async validator() {
|
|
583
671
|
await input.task
|
package/Task.js
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
const CancelToken = require('promise-toolbox/CancelToken')
|
|
4
4
|
const Zone = require('node-zone')
|
|
5
5
|
|
|
6
|
-
const logAfterEnd =
|
|
7
|
-
|
|
6
|
+
const logAfterEnd = log => {
|
|
7
|
+
const error = new Error('task has already ended')
|
|
8
|
+
error.log = log
|
|
9
|
+
throw error
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
const noop = Function.prototype
|
package/_VmBackup.js
CHANGED
|
@@ -128,42 +128,49 @@ class VmBackup {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
// calls fn for each function, warns of any errors, and throws only if there are no writers left
|
|
131
|
-
async _callWriters(fn,
|
|
131
|
+
async _callWriters(fn, step, parallel = true) {
|
|
132
132
|
const writers = this._writers
|
|
133
133
|
const n = writers.size
|
|
134
134
|
if (n === 0) {
|
|
135
135
|
return
|
|
136
136
|
}
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
|
|
138
|
+
async function callWriter(writer) {
|
|
139
|
+
const { name } = writer.constructor
|
|
139
140
|
try {
|
|
141
|
+
debug('writer step starting', { step, writer: name })
|
|
140
142
|
await fn(writer)
|
|
143
|
+
debug('writer step succeeded', { duration: step, writer: name })
|
|
141
144
|
} catch (error) {
|
|
142
145
|
writers.delete(writer)
|
|
146
|
+
|
|
147
|
+
warn('writer step failed', { error, step, writer: name })
|
|
148
|
+
|
|
149
|
+
// these two steps are the only one that are not already in their own sub tasks
|
|
150
|
+
if (step === 'writer.checkBaseVdis()' || step === 'writer.beforeBackup()') {
|
|
151
|
+
Task.warning(
|
|
152
|
+
`the writer ${name} has failed the step ${step} with error ${error.message}. It won't be used anymore in this job execution.`
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
143
156
|
throw error
|
|
144
157
|
}
|
|
145
|
-
|
|
158
|
+
}
|
|
159
|
+
if (n === 1) {
|
|
160
|
+
const [writer] = writers
|
|
161
|
+
return callWriter(writer)
|
|
146
162
|
}
|
|
147
163
|
|
|
148
164
|
const errors = []
|
|
149
165
|
await (parallel ? asyncMap : asyncEach)(writers, async function (writer) {
|
|
150
166
|
try {
|
|
151
|
-
await
|
|
167
|
+
await callWriter(writer)
|
|
152
168
|
} catch (error) {
|
|
153
169
|
errors.push(error)
|
|
154
|
-
this.delete(writer)
|
|
155
|
-
warn(warnMessage, { error, writer: writer.constructor.name })
|
|
156
|
-
|
|
157
|
-
// these two steps are the only one that are not already in their own sub tasks
|
|
158
|
-
if (warnMessage === 'writer.checkBaseVdis()' || warnMessage === 'writer.beforeBackup()') {
|
|
159
|
-
Task.warning(
|
|
160
|
-
`the writer ${writer.constructor.name} has failed the step ${warnMessage} with error ${error.message}. It won't be used anymore in this job execution.`
|
|
161
|
-
)
|
|
162
|
-
}
|
|
163
170
|
}
|
|
164
171
|
})
|
|
165
172
|
if (writers.size === 0) {
|
|
166
|
-
throw new AggregateError(errors, 'all targets have failed, step: ' +
|
|
173
|
+
throw new AggregateError(errors, 'all targets have failed, step: ' + step)
|
|
167
174
|
}
|
|
168
175
|
}
|
|
169
176
|
|
package/_cleanVm.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const assert = require('assert')
|
|
4
3
|
const sum = require('lodash/sum')
|
|
5
4
|
const UUID = require('uuid')
|
|
6
5
|
const { asyncMap } = require('@xen-orchestra/async-map')
|
|
7
|
-
const { Constants,
|
|
6
|
+
const { Constants, openVhd, VhdAbstract, VhdFile } = require('vhd-lib')
|
|
8
7
|
const { isVhdAlias, resolveVhdAlias } = require('vhd-lib/aliases')
|
|
9
8
|
const { dirname, resolve } = require('path')
|
|
10
9
|
const { DISK_TYPES } = Constants
|
|
11
10
|
const { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } = require('./_backupType.js')
|
|
12
11
|
const { limitConcurrency } = require('limit-concurrency-decorator')
|
|
12
|
+
const { mergeVhdChain } = require('vhd-lib/merge')
|
|
13
13
|
|
|
14
14
|
const { Task } = require('./Task.js')
|
|
15
15
|
const { Disposable } = require('promise-toolbox')
|
|
@@ -18,7 +18,10 @@ const handlerPath = require('@xen-orchestra/fs/path')
|
|
|
18
18
|
// checking the size of a vhd directory is costly
|
|
19
19
|
// 1 Http Query per 1000 blocks
|
|
20
20
|
// we only check size of all the vhd are VhdFiles
|
|
21
|
-
function shouldComputeVhdsSize(vhds) {
|
|
21
|
+
function shouldComputeVhdsSize(handler, vhds) {
|
|
22
|
+
if (handler.isEncrypted) {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
22
25
|
return vhds.every(vhd => vhd instanceof VhdFile)
|
|
23
26
|
}
|
|
24
27
|
|
|
@@ -26,61 +29,42 @@ const computeVhdsSize = (handler, vhdPaths) =>
|
|
|
26
29
|
Disposable.use(
|
|
27
30
|
vhdPaths.map(vhdPath => openVhd(handler, vhdPath)),
|
|
28
31
|
async vhds => {
|
|
29
|
-
if (shouldComputeVhdsSize(vhds)) {
|
|
32
|
+
if (shouldComputeVhdsSize(handler, vhds)) {
|
|
30
33
|
const sizes = await asyncMap(vhds, vhd => vhd.getSize())
|
|
31
34
|
return sum(sizes)
|
|
32
35
|
}
|
|
33
36
|
}
|
|
34
37
|
)
|
|
35
38
|
|
|
36
|
-
// chain is [ ancestor,
|
|
37
|
-
|
|
38
|
-
// 2. Merge the VhdSynthetic into the ancestor
|
|
39
|
-
// 3. Delete all (now) unused VHDs
|
|
40
|
-
// 4. Rename the ancestor with the merged data to the latest child
|
|
41
|
-
//
|
|
42
|
-
// VhdSynthetic
|
|
43
|
-
// |
|
|
44
|
-
// /‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
|
|
45
|
-
// [ ancestor, child1, ...,child n-1, childn ]
|
|
46
|
-
// | \___________________/ ^
|
|
47
|
-
// | | |
|
|
48
|
-
// | unused VHDs |
|
|
49
|
-
// | |
|
|
50
|
-
// \___________rename_____________/
|
|
51
|
-
|
|
52
|
-
async function mergeVhdChain(chain, { handler, logInfo, remove, merge }) {
|
|
53
|
-
assert(chain.length >= 2)
|
|
54
|
-
const chainCopy = [...chain]
|
|
55
|
-
const parent = chainCopy.shift()
|
|
56
|
-
const children = chainCopy
|
|
57
|
-
|
|
39
|
+
// chain is [ ancestor, child_1, ..., child_n ]
|
|
40
|
+
async function _mergeVhdChain(handler, chain, { logInfo, remove, merge, mergeBlockConcurrency }) {
|
|
58
41
|
if (merge) {
|
|
59
|
-
logInfo(
|
|
42
|
+
logInfo(`merging VHD chain`, { chain })
|
|
60
43
|
|
|
61
44
|
let done, total
|
|
62
45
|
const handle = setInterval(() => {
|
|
63
46
|
if (done !== undefined) {
|
|
64
47
|
logInfo('merge in progress', {
|
|
65
48
|
done,
|
|
66
|
-
parent,
|
|
49
|
+
parent: chain[0],
|
|
67
50
|
progress: Math.round((100 * done) / total),
|
|
68
51
|
total,
|
|
69
52
|
})
|
|
70
53
|
}
|
|
71
54
|
}, 10e3)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
done
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
55
|
+
try {
|
|
56
|
+
return await mergeVhdChain(handler, chain, {
|
|
57
|
+
logInfo,
|
|
58
|
+
mergeBlockConcurrency,
|
|
59
|
+
onProgress({ done: d, total: t }) {
|
|
60
|
+
done = d
|
|
61
|
+
total = t
|
|
62
|
+
},
|
|
63
|
+
removeUnused: remove,
|
|
64
|
+
})
|
|
65
|
+
} finally {
|
|
66
|
+
clearInterval(handle)
|
|
67
|
+
}
|
|
84
68
|
}
|
|
85
69
|
}
|
|
86
70
|
|
|
@@ -114,7 +98,7 @@ const listVhds = async (handler, vmDir, logWarn) => {
|
|
|
114
98
|
vhds.add(`${vdiDir}/${file}`)
|
|
115
99
|
} else {
|
|
116
100
|
try {
|
|
117
|
-
const mergeState = JSON.parse(await handler.readFile(file))
|
|
101
|
+
const mergeState = JSON.parse(await handler.readFile(`${vdiDir}/${file}`))
|
|
118
102
|
interruptedVhds.set(`${vdiDir}/${res[1]}`, {
|
|
119
103
|
statePath: `${vdiDir}/${file}`,
|
|
120
104
|
chain: mergeState.chain,
|
|
@@ -198,9 +182,17 @@ const defaultMergeLimiter = limitConcurrency(1)
|
|
|
198
182
|
|
|
199
183
|
exports.cleanVm = async function cleanVm(
|
|
200
184
|
vmDir,
|
|
201
|
-
{
|
|
185
|
+
{
|
|
186
|
+
fixMetadata,
|
|
187
|
+
remove,
|
|
188
|
+
merge,
|
|
189
|
+
mergeBlockConcurrency,
|
|
190
|
+
mergeLimiter = defaultMergeLimiter,
|
|
191
|
+
logInfo = noop,
|
|
192
|
+
logWarn = console.warn,
|
|
193
|
+
}
|
|
202
194
|
) {
|
|
203
|
-
const limitedMergeVhdChain = mergeLimiter(
|
|
195
|
+
const limitedMergeVhdChain = mergeLimiter(_mergeVhdChain)
|
|
204
196
|
|
|
205
197
|
const handler = this._handler
|
|
206
198
|
|
|
@@ -319,6 +311,7 @@ exports.cleanVm = async function cleanVm(
|
|
|
319
311
|
}
|
|
320
312
|
|
|
321
313
|
const jsons = new Set()
|
|
314
|
+
let mustInvalidateCache = false
|
|
322
315
|
const xvas = new Set()
|
|
323
316
|
const xvaSums = []
|
|
324
317
|
const entries = await handler.list(vmDir, {
|
|
@@ -367,6 +360,7 @@ exports.cleanVm = async function cleanVm(
|
|
|
367
360
|
if (remove) {
|
|
368
361
|
logInfo('deleting incomplete backup', { path: json })
|
|
369
362
|
jsons.delete(json)
|
|
363
|
+
mustInvalidateCache = true
|
|
370
364
|
await handler.unlink(json)
|
|
371
365
|
}
|
|
372
366
|
}
|
|
@@ -389,6 +383,7 @@ exports.cleanVm = async function cleanVm(
|
|
|
389
383
|
logWarn('some VHDs linked to the backup are missing', { backup: json, missingVhds })
|
|
390
384
|
if (remove) {
|
|
391
385
|
logInfo('deleting incomplete backup', { path: json })
|
|
386
|
+
mustInvalidateCache = true
|
|
392
387
|
jsons.delete(json)
|
|
393
388
|
await handler.unlink(json)
|
|
394
389
|
}
|
|
@@ -461,7 +456,13 @@ exports.cleanVm = async function cleanVm(
|
|
|
461
456
|
const metadataWithMergedVhd = {}
|
|
462
457
|
const doMerge = async () => {
|
|
463
458
|
await asyncMap(toMerge, async chain => {
|
|
464
|
-
const merged = await limitedMergeVhdChain(
|
|
459
|
+
const merged = await limitedMergeVhdChain(handler, chain, {
|
|
460
|
+
logInfo,
|
|
461
|
+
logWarn,
|
|
462
|
+
remove,
|
|
463
|
+
merge,
|
|
464
|
+
mergeBlockConcurrency,
|
|
465
|
+
})
|
|
465
466
|
if (merged !== undefined) {
|
|
466
467
|
const metadataPath = vhdsToJSons[chain[chain.length - 1]] // all the chain should have the same metada file
|
|
467
468
|
metadataWithMergedVhd[metadataPath] = true
|
|
@@ -506,7 +507,11 @@ exports.cleanVm = async function cleanVm(
|
|
|
506
507
|
if (mode === 'full') {
|
|
507
508
|
// a full backup : check size
|
|
508
509
|
const linkedXva = resolve('/', vmDir, xva)
|
|
509
|
-
|
|
510
|
+
try {
|
|
511
|
+
fileSystemSize = await handler.getSize(linkedXva)
|
|
512
|
+
} catch (error) {
|
|
513
|
+
// can fail with encrypted remote
|
|
514
|
+
}
|
|
510
515
|
} else if (mode === 'delta') {
|
|
511
516
|
const linkedVhds = Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
|
|
512
517
|
fileSystemSize = await computeVhdsSize(handler, linkedVhds)
|
|
@@ -541,6 +546,11 @@ exports.cleanVm = async function cleanVm(
|
|
|
541
546
|
}
|
|
542
547
|
})
|
|
543
548
|
|
|
549
|
+
// purge cache if a metadata file has been deleted
|
|
550
|
+
if (mustInvalidateCache) {
|
|
551
|
+
await handler.unlink(vmDir + '/cache.json.gz')
|
|
552
|
+
}
|
|
553
|
+
|
|
544
554
|
return {
|
|
545
555
|
// boolean whether some VHDs were merged (or should be merged)
|
|
546
556
|
merge: toMerge.length !== 0,
|
package/_deltaVm.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const compareVersions = require('compare-versions')
|
|
4
3
|
const find = require('lodash/find.js')
|
|
5
4
|
const groupBy = require('lodash/groupBy.js')
|
|
6
5
|
const ignoreErrors = require('promise-toolbox/ignoreErrors')
|
|
7
6
|
const omit = require('lodash/omit.js')
|
|
8
7
|
const { asyncMap } = require('@xen-orchestra/async-map')
|
|
9
8
|
const { CancelToken } = require('promise-toolbox')
|
|
9
|
+
const { compareVersions } = require('compare-versions')
|
|
10
10
|
const { createVhdStreamWithLength } = require('vhd-lib')
|
|
11
11
|
const { defer } = require('golike-defer')
|
|
12
12
|
|
package/_forkStreamUnpipe.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
const eos = require('end-of-stream')
|
|
4
4
|
const { PassThrough } = require('stream')
|
|
5
5
|
|
|
6
|
+
const { debug } = require('@xen-orchestra/log').createLogger('xo:backups:forkStreamUnpipe')
|
|
7
|
+
|
|
6
8
|
// create a new readable stream from an existing one which may be piped later
|
|
7
9
|
//
|
|
8
10
|
// in case of error in the new readable stream, it will simply be unpiped
|
|
@@ -11,18 +13,23 @@ exports.forkStreamUnpipe = function forkStreamUnpipe(stream) {
|
|
|
11
13
|
const { forks = 0 } = stream
|
|
12
14
|
stream.forks = forks + 1
|
|
13
15
|
|
|
16
|
+
debug('forking', { forks: stream.forks })
|
|
17
|
+
|
|
14
18
|
const proxy = new PassThrough()
|
|
15
19
|
stream.pipe(proxy)
|
|
16
20
|
eos(stream, error => {
|
|
17
21
|
if (error !== undefined) {
|
|
22
|
+
debug('error on original stream, destroying fork', { error })
|
|
18
23
|
proxy.destroy(error)
|
|
19
24
|
}
|
|
20
25
|
})
|
|
21
|
-
eos(proxy,
|
|
22
|
-
stream.forks
|
|
26
|
+
eos(proxy, error => {
|
|
27
|
+
debug('end of stream, unpiping', { error, forks: --stream.forks })
|
|
28
|
+
|
|
23
29
|
stream.unpipe(proxy)
|
|
24
30
|
|
|
25
31
|
if (stream.forks === 0) {
|
|
32
|
+
debug('no more forks, destroying original stream')
|
|
26
33
|
stream.destroy(new Error('no more consumers for this stream'))
|
|
27
34
|
}
|
|
28
35
|
})
|
package/_isValidXva.js
CHANGED
|
@@ -49,6 +49,11 @@ const isValidTar = async (handler, size, fd) => {
|
|
|
49
49
|
// TODO: find an heuristic for compressed files
|
|
50
50
|
async function isValidXva(path) {
|
|
51
51
|
const handler = this._handler
|
|
52
|
+
|
|
53
|
+
// size is longer when encrypted + reading part of an encrypted file is not implemented
|
|
54
|
+
if (handler.isEncrypted) {
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
52
57
|
try {
|
|
53
58
|
const fd = await handler.openFile(path, 'r')
|
|
54
59
|
try {
|
|
@@ -66,7 +71,6 @@ async function isValidXva(path) {
|
|
|
66
71
|
}
|
|
67
72
|
} catch (error) {
|
|
68
73
|
// never throw, log and report as valid to avoid side effects
|
|
69
|
-
console.error('isValidXva', path, error)
|
|
70
74
|
return true
|
|
71
75
|
}
|
|
72
76
|
}
|
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.28.0",
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=14.6"
|
|
14
14
|
},
|
|
@@ -16,16 +16,18 @@
|
|
|
16
16
|
"postversion": "npm publish --access public"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
+
"@vates/async-each": "^1.0.0",
|
|
19
20
|
"@vates/cached-dns.lookup": "^1.0.0",
|
|
20
21
|
"@vates/compose": "^2.1.0",
|
|
21
22
|
"@vates/decorate-with": "^2.0.0",
|
|
22
23
|
"@vates/disposable": "^0.1.1",
|
|
24
|
+
"@vates/fuse-vhd": "^1.0.0",
|
|
23
25
|
"@vates/parse-duration": "^0.1.1",
|
|
24
26
|
"@xen-orchestra/async-map": "^0.1.2",
|
|
25
|
-
"@xen-orchestra/fs": "^
|
|
27
|
+
"@xen-orchestra/fs": "^3.1.0",
|
|
26
28
|
"@xen-orchestra/log": "^0.3.0",
|
|
27
29
|
"@xen-orchestra/template": "^0.1.0",
|
|
28
|
-
"compare-versions": "^
|
|
30
|
+
"compare-versions": "^5.0.1",
|
|
29
31
|
"d3-time-format": "^3.0.0",
|
|
30
32
|
"decorator-synchronized": "^0.6.0",
|
|
31
33
|
"end-of-stream": "^1.4.4",
|
|
@@ -37,8 +39,8 @@
|
|
|
37
39
|
"parse-pairs": "^1.1.0",
|
|
38
40
|
"promise-toolbox": "^0.21.0",
|
|
39
41
|
"proper-lockfile": "^4.1.2",
|
|
40
|
-
"uuid": "^
|
|
41
|
-
"vhd-lib": "^
|
|
42
|
+
"uuid": "^9.0.0",
|
|
43
|
+
"vhd-lib": "^4.1.0",
|
|
42
44
|
"yazl": "^2.5.1"
|
|
43
45
|
},
|
|
44
46
|
"devDependencies": {
|
|
@@ -46,7 +48,7 @@
|
|
|
46
48
|
"tmp": "^0.2.1"
|
|
47
49
|
},
|
|
48
50
|
"peerDependencies": {
|
|
49
|
-
"@xen-orchestra/xapi": "^1.
|
|
51
|
+
"@xen-orchestra/xapi": "^1.5.0"
|
|
50
52
|
},
|
|
51
53
|
"license": "AGPL-3.0-or-later",
|
|
52
54
|
"author": {
|
|
@@ -19,8 +19,6 @@ const { AbstractDeltaWriter } = require('./_AbstractDeltaWriter.js')
|
|
|
19
19
|
const { checkVhd } = require('./_checkVhd.js')
|
|
20
20
|
const { packUuid } = require('./_packUuid.js')
|
|
21
21
|
const { Disposable } = require('promise-toolbox')
|
|
22
|
-
const { HealthCheckVmBackup } = require('../HealthCheckVmBackup.js')
|
|
23
|
-
const { ImportVmBackup } = require('../ImportVmBackup.js')
|
|
24
22
|
|
|
25
23
|
const { warn } = createLogger('xo:backups:DeltaBackupWriter')
|
|
26
24
|
|
|
@@ -38,6 +36,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
|
|
38
36
|
try {
|
|
39
37
|
const vhds = await handler.list(`${vdisDir}/${srcVdi.uuid}`, {
|
|
40
38
|
filter: _ => _[0] !== '.' && _.endsWith('.vhd'),
|
|
39
|
+
ignoreMissing: true,
|
|
41
40
|
prependDir: true,
|
|
42
41
|
})
|
|
43
42
|
const packedBaseUuid = packUuid(baseUuid)
|
|
@@ -71,35 +70,6 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
|
|
71
70
|
return this._cleanVm({ merge: true })
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
healthCheck(sr) {
|
|
75
|
-
return Task.run(
|
|
76
|
-
{
|
|
77
|
-
name: 'health check',
|
|
78
|
-
},
|
|
79
|
-
async () => {
|
|
80
|
-
const xapi = sr.$xapi
|
|
81
|
-
const srUuid = sr.uuid
|
|
82
|
-
const adapter = this._adapter
|
|
83
|
-
const metadata = await adapter.readVmBackupMetadata(this._metadataFileName)
|
|
84
|
-
const { id: restoredId } = await new ImportVmBackup({
|
|
85
|
-
adapter,
|
|
86
|
-
metadata,
|
|
87
|
-
srUuid,
|
|
88
|
-
xapi,
|
|
89
|
-
}).run()
|
|
90
|
-
const restoredVm = xapi.getObject(restoredId)
|
|
91
|
-
try {
|
|
92
|
-
await new HealthCheckVmBackup({
|
|
93
|
-
restoredVm,
|
|
94
|
-
xapi,
|
|
95
|
-
}).run()
|
|
96
|
-
} finally {
|
|
97
|
-
await xapi.VM_destroy(restoredVm.$ref)
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
73
|
prepare({ isFull }) {
|
|
104
74
|
// create the task related to this export and ensure all methods are called in this context
|
|
105
75
|
const task = new Task({
|
|
@@ -189,7 +159,6 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
|
|
189
159
|
}/${adapter.getVhdFileName(basename)}`
|
|
190
160
|
)
|
|
191
161
|
|
|
192
|
-
const metadataFilename = (this._metadataFileName = `${backupDir}/${basename}.json`)
|
|
193
162
|
const metadataContent = {
|
|
194
163
|
jobId,
|
|
195
164
|
mode: job.mode,
|
|
@@ -235,6 +204,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
|
|
235
204
|
// merges and chainings
|
|
236
205
|
checksum: false,
|
|
237
206
|
validator: tmpPath => checkVhd(handler, tmpPath),
|
|
207
|
+
writeBlockConcurrency: this._backup.config.writeBlockConcurrency,
|
|
238
208
|
})
|
|
239
209
|
|
|
240
210
|
if (isDelta) {
|
|
@@ -254,9 +224,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
|
|
254
224
|
}
|
|
255
225
|
})
|
|
256
226
|
metadataContent.size = size
|
|
257
|
-
await
|
|
258
|
-
dirMode: backup.config.dirMode,
|
|
259
|
-
})
|
|
227
|
+
this._metadataFileName = await adapter.writeVmBackupMetadata(vm.uuid, metadataContent)
|
|
260
228
|
|
|
261
229
|
// TODO: run cleanup?
|
|
262
230
|
}
|
|
@@ -34,7 +34,6 @@ exports.FullBackupWriter = class FullBackupWriter extends MixinBackupWriter(Abst
|
|
|
34
34
|
const { job, scheduleId, vm } = backup
|
|
35
35
|
|
|
36
36
|
const adapter = this._adapter
|
|
37
|
-
const handler = adapter.handler
|
|
38
37
|
const backupDir = getVmBackupDir(vm.uuid)
|
|
39
38
|
|
|
40
39
|
// TODO: clean VM backup directory
|
|
@@ -74,9 +73,7 @@ exports.FullBackupWriter = class FullBackupWriter extends MixinBackupWriter(Abst
|
|
|
74
73
|
return { size: sizeContainer.size }
|
|
75
74
|
})
|
|
76
75
|
metadata.size = sizeContainer.size
|
|
77
|
-
await
|
|
78
|
-
dirMode: backup.config.dirMode,
|
|
79
|
-
})
|
|
76
|
+
this._metadataFileName = await adapter.writeVmBackupMetadata(vm.uuid, metadata)
|
|
80
77
|
|
|
81
78
|
if (!deleteFirst) {
|
|
82
79
|
await deleteOldBackups()
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
const { createLogger } = require('@xen-orchestra/log')
|
|
4
4
|
const { join } = require('path')
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
const MergeWorker = require('../merge-worker/index.js')
|
|
6
|
+
const assert = require('assert')
|
|
8
7
|
const { formatFilenameDate } = require('../_filenameDate.js')
|
|
8
|
+
const { getVmBackupDir } = require('../_getVmBackupDir.js')
|
|
9
|
+
const { HealthCheckVmBackup } = require('../HealthCheckVmBackup.js')
|
|
10
|
+
const { ImportVmBackup } = require('../ImportVmBackup.js')
|
|
9
11
|
const { Task } = require('../Task.js')
|
|
12
|
+
const MergeWorker = require('../merge-worker/index.js')
|
|
10
13
|
|
|
11
14
|
const { info, warn } = createLogger('xo:backups:MixinBackupWriter')
|
|
12
15
|
|
|
@@ -36,6 +39,7 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
|
|
|
36
39
|
Task.warning(message, data)
|
|
37
40
|
},
|
|
38
41
|
lock: false,
|
|
42
|
+
mergeBlockConcurrency: this._backup.config.mergeBlockConcurrency,
|
|
39
43
|
})
|
|
40
44
|
})
|
|
41
45
|
} catch (error) {
|
|
@@ -71,6 +75,39 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
|
|
|
71
75
|
const remotePath = handler._getRealPath()
|
|
72
76
|
await MergeWorker.run(remotePath)
|
|
73
77
|
}
|
|
74
|
-
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
healthCheck(sr) {
|
|
81
|
+
assert.notStrictEqual(
|
|
82
|
+
this._metadataFileName,
|
|
83
|
+
undefined,
|
|
84
|
+
'Metadata file name should be defined before making a healthcheck'
|
|
85
|
+
)
|
|
86
|
+
return Task.run(
|
|
87
|
+
{
|
|
88
|
+
name: 'health check',
|
|
89
|
+
},
|
|
90
|
+
async () => {
|
|
91
|
+
const xapi = sr.$xapi
|
|
92
|
+
const srUuid = sr.uuid
|
|
93
|
+
const adapter = this._adapter
|
|
94
|
+
const metadata = await adapter.readVmBackupMetadata(this._metadataFileName)
|
|
95
|
+
const { id: restoredId } = await new ImportVmBackup({
|
|
96
|
+
adapter,
|
|
97
|
+
metadata,
|
|
98
|
+
srUuid,
|
|
99
|
+
xapi,
|
|
100
|
+
}).run()
|
|
101
|
+
const restoredVm = xapi.getObject(restoredId)
|
|
102
|
+
try {
|
|
103
|
+
await new HealthCheckVmBackup({
|
|
104
|
+
restoredVm,
|
|
105
|
+
xapi,
|
|
106
|
+
}).run()
|
|
107
|
+
} finally {
|
|
108
|
+
await xapi.VM_destroy(restoredVm.$ref)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
)
|
|
75
112
|
}
|
|
76
113
|
}
|