@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 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
- const options = ['loop', 'ro']
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
- const dirs = new Set(files.map(file => dirname(file)))
280
- for (const dir of dirs) {
281
- // don't merge in main process, unused VHDs will be merged in the next backup run
282
- await this.cleanVm(dir, { remove: true, logWarn: warn })
283
- }
284
-
285
- const dedupedVmUuid = new Set(metadatas.map(_ => _.vm.uuid))
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
- async *getDisk(diskId) {
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 asyncMap(await readdir(path), async name => {
384
- try {
385
- const stats = await lstat(`${path}/${name}`)
386
- if (stats.isDirectory()) {
387
- entriesMap[name + '/'] = {}
388
- } else if (stats.isFile()) {
389
- entriesMap[name] = {}
390
- }
391
- } catch (error) {
392
- if (error == null || error.code !== 'ENOENT') {
393
- throw error
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(`${BACKUP_DIR}/${vmUuid}/cache.json.gz`)
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 dir = `${BACKUP_DIR}/${vmUuid}`
502
- const path = `${dir}/cache.json.gz`
582
+ const path = this.#getVmBackupsCache(vmUuid)
503
583
 
504
- try {
505
- const gzipped = await this.handler.readFile(path)
506
- const text = await fromCallback(zlib.gunzip, gzipped)
507
- return JSON.parse(text)
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(dir)
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.#writeVmBackupsCache(path, backups)
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 writeVhd(path, input, { checksum = true, validator = noop } = {}) {
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: 16,
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
- throw new Error('task has already ended')
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, warnMessage, parallel = true) {
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
- if (n === 1) {
138
- const [writer] = writers
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
- return
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 fn(writer)
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: ' + warnMessage)
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, mergeVhd, openVhd, VhdAbstract, VhdFile } = require('vhd-lib')
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, child1, ..., childn]
37
- // 1. Create a VhdSynthetic from all children
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('will merge children into parent', { children, parent })
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
- const mergedSize = await mergeVhd(handler, parent, handler, children, {
74
- logInfo,
75
- onProgress({ done: d, total: t }) {
76
- done = d
77
- total = t
78
- },
79
- remove,
80
- })
81
-
82
- clearInterval(handle)
83
- return mergedSize
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
- { fixMetadata, remove, merge, mergeLimiter = defaultMergeLimiter, logInfo = noop, logWarn = console.warn }
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(mergeVhdChain)
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(chain, { handler, logInfo, logWarn, remove, merge })
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
- fileSystemSize = await handler.getSize(linkedXva)
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
 
@@ -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.27.3",
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": "^2.0.0",
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": "^4.0.1",
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": "^8.3.2",
41
- "vhd-lib": "^3.3.4",
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.4.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 handler.outputFile(metadataFilename, JSON.stringify(metadataContent), {
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 handler.outputFile(metadataFilename, JSON.stringify(metadata), {
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 { getVmBackupDir } = require('../_getVmBackupDir.js')
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
- await this._adapter.invalidateVmBackupListCache(this._backup.vm.uuid)
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
  }