@xen-orchestra/backups 0.16.0 → 0.17.1

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/RemoteAdapter.js CHANGED
@@ -3,9 +3,10 @@ const Disposable = require('promise-toolbox/Disposable.js')
3
3
  const fromCallback = require('promise-toolbox/fromCallback.js')
4
4
  const fromEvent = require('promise-toolbox/fromEvent.js')
5
5
  const pDefer = require('promise-toolbox/defer.js')
6
+ const groupBy = require('lodash/groupBy.js')
6
7
  const { dirname, join, normalize, resolve } = require('path')
7
8
  const { createLogger } = require('@xen-orchestra/log')
8
- const { VhdAbstract, createVhdDirectoryFromStream } = require('vhd-lib')
9
+ const { Constants, createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdSynthetic } = require('vhd-lib')
9
10
  const { deduped } = require('@vates/disposable/deduped.js')
10
11
  const { execFile } = require('child_process')
11
12
  const { readdir, stat } = require('fs-extra')
@@ -243,17 +244,22 @@ class RemoteAdapter {
243
244
  )
244
245
  }
245
246
 
246
- async deleteVmBackup(filename) {
247
- const metadata = JSON.parse(String(await this._handler.readFile(filename)))
248
- metadata._filename = filename
247
+ deleteVmBackup(file) {
248
+ return this.deleteVmBackups([file])
249
+ }
249
250
 
250
- if (metadata.mode === 'delta') {
251
- await this.deleteDeltaVmBackups([metadata])
252
- } else if (metadata.mode === 'full') {
253
- await this.deleteFullVmBackups([metadata])
254
- } else {
255
- throw new Error(`no deleter for backup mode ${metadata.mode}`)
251
+ async deleteVmBackups(files) {
252
+ const { delta, full, ...others } = groupBy(await asyncMap(files, file => this.readVmBackupMetadata(file)), 'mode')
253
+
254
+ const unsupportedModes = Object.keys(others)
255
+ if (unsupportedModes.length !== 0) {
256
+ throw new Error('no deleter for backup modes: ' + unsupportedModes.join(', '))
256
257
  }
258
+
259
+ await Promise.all([
260
+ delta !== undefined && this.deleteDeltaVmBackups(delta),
261
+ full !== undefined && this.deleteFullVmBackups(full),
262
+ ])
257
263
  }
258
264
 
259
265
  getDisk = Disposable.factory(this.getDisk)
@@ -469,10 +475,10 @@ class RemoteAdapter {
469
475
 
470
476
  async writeVhd(path, input, { checksum = true, validator = noop } = {}) {
471
477
  const handler = this._handler
472
- let dataPath = path
473
478
 
474
479
  if (path.endsWith('.alias.vhd')) {
475
- await createVhdDirectoryFromStream(handler, `${dirname(path)}/data/${uuidv4()}.vhd`, input, {
480
+ const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
481
+ await createVhdDirectoryFromStream(handler, dataPath, input, {
476
482
  concurrency: 16,
477
483
  async validator() {
478
484
  await input.task
@@ -481,7 +487,7 @@ class RemoteAdapter {
481
487
  })
482
488
  await VhdAbstract.createAlias(handler, path, dataPath)
483
489
  } else {
484
- await this.outputStream(dataPath, input, { checksum, validator })
490
+ await this.outputStream(path, input, { checksum, validator })
485
491
  }
486
492
  }
487
493
 
@@ -497,17 +503,48 @@ class RemoteAdapter {
497
503
  }
498
504
 
499
505
  async _createSyntheticStream(handler, paths) {
506
+ let disposableVhds = []
507
+
508
+ // if it's a path : open all hierarchy of parent
509
+ if (typeof paths === 'string') {
510
+ let vhd,
511
+ vhdPath = paths
512
+ do {
513
+ const disposable = await openVhd(handler, vhdPath)
514
+ vhd = disposable.value
515
+ disposableVhds.push(disposable)
516
+ vhdPath = resolveRelativeFromFile(vhdPath, vhd.header.parentUnicodeName)
517
+ } while (vhd.footer.diskType !== Constants.DISK_TYPES.DYNAMIC)
518
+ } else {
519
+ // only open the list of path given
520
+ disposableVhds = paths.map(path => openVhd(handler, path))
521
+ }
522
+
500
523
  // I don't want the vhds to be disposed on return
501
524
  // but only when the stream is done ( or failed )
502
- const disposables = await Disposable.all(paths.map(path => openVhd(handler, path)))
525
+ const disposables = await Disposable.all(disposableVhds)
503
526
  const vhds = disposables.value
527
+
528
+ let disposed = false
529
+ const disposeOnce = async () => {
530
+ if (!disposed) {
531
+ disposed = true
532
+
533
+ try {
534
+ await disposables.dispose()
535
+ } catch (error) {
536
+ warn('_createSyntheticStream: failed to dispose VHDs', { error })
537
+ }
538
+ }
539
+ }
540
+
504
541
  const synthetic = new VhdSynthetic(vhds)
505
542
  await synthetic.readHeaderAndFooter()
506
543
  await synthetic.readBlockAllocationTable()
507
544
  const stream = await synthetic.stream()
508
- stream.on('end', () => disposables.dispose())
509
- stream.on('close', () => disposables.dispose())
510
- stream.on('error', () => disposables.dispose())
545
+ stream.on('end', disposeOnce)
546
+ stream.on('close', disposeOnce)
547
+ stream.on('error', disposeOnce)
511
548
  return stream
512
549
  }
513
550
 
package/_VmBackup.js CHANGED
@@ -36,6 +36,11 @@ const forkDeltaExport = deltaExport =>
36
36
 
37
37
  exports.VmBackup = class VmBackup {
38
38
  constructor({ config, getSnapshotNameLabel, job, remoteAdapters, remotes, schedule, settings, srs, vm }) {
39
+ if (vm.other_config['xo:backup:job'] === job.id) {
40
+ // otherwise replicated VMs would be matched and replicated again and again
41
+ throw new Error('cannot backup a VM created by this very job')
42
+ }
43
+
39
44
  this.config = config
40
45
  this.job = job
41
46
  this.remoteAdapters = remoteAdapters
@@ -333,13 +338,16 @@ exports.VmBackup = class VmBackup {
333
338
 
334
339
  const baseUuidToSrcVdi = new Map()
335
340
  await asyncMap(await baseVm.$getDisks(), async baseRef => {
336
- const snapshotOf = await xapi.getField('VDI', baseRef, 'snapshot_of')
341
+ const [baseUuid, snapshotOf] = await Promise.all([
342
+ xapi.getField('VDI', baseRef, 'uuid'),
343
+ xapi.getField('VDI', baseRef, 'snapshot_of'),
344
+ ])
337
345
  const srcVdi = srcVdis[snapshotOf]
338
346
  if (srcVdi !== undefined) {
339
- baseUuidToSrcVdi.set(await xapi.getField('VDI', baseRef, 'uuid'), srcVdi)
347
+ baseUuidToSrcVdi.set(baseUuid, srcVdi)
340
348
  } else {
341
- debug('no base VDI found', {
342
- vdi: srcVdi.uuid,
349
+ debug('ignore snapshot VDI because no longer present on VM', {
350
+ vdi: baseUuid,
343
351
  })
344
352
  }
345
353
  })
@@ -351,6 +359,11 @@ exports.VmBackup = class VmBackup {
351
359
  false
352
360
  )
353
361
 
362
+ if (presentBaseVdis.size === 0) {
363
+ debug('no base VM found')
364
+ return
365
+ }
366
+
354
367
  const fullVdisRequired = new Set()
355
368
  baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
356
369
  if (presentBaseVdis.has(baseUuid)) {
package/_cleanVm.js CHANGED
@@ -202,7 +202,7 @@ exports.cleanVm = async function cleanVm(
202
202
  await Promise.all(deletions)
203
203
  }
204
204
 
205
- const jsons = []
205
+ const jsons = new Set()
206
206
  const xvas = new Set()
207
207
  const xvaSums = []
208
208
  const entries = await handler.list(vmDir, {
@@ -210,7 +210,7 @@ exports.cleanVm = async function cleanVm(
210
210
  })
211
211
  entries.forEach(path => {
212
212
  if (isMetadataFile(path)) {
213
- jsons.push(path)
213
+ jsons.add(path)
214
214
  } else if (isXvaFile(path)) {
215
215
  xvas.add(path)
216
216
  } else if (isXvaSumFile(path)) {
@@ -232,7 +232,15 @@ exports.cleanVm = async function cleanVm(
232
232
  // compile the list of unused XVAs and VHDs, and remove backup metadata which
233
233
  // reference a missing XVA/VHD
234
234
  await asyncMap(jsons, async json => {
235
- const metadata = JSON.parse(await handler.readFile(json))
235
+ let metadata
236
+ try {
237
+ metadata = JSON.parse(await handler.readFile(json))
238
+ } catch (error) {
239
+ onLog(`failed to read metadata file ${json}`, { error })
240
+ jsons.delete(json)
241
+ return
242
+ }
243
+
236
244
  const { mode } = metadata
237
245
  let size
238
246
  if (mode === 'full') {
@@ -248,6 +256,7 @@ exports.cleanVm = async function cleanVm(
248
256
  onLog(`the XVA linked to the metadata ${json} is missing`)
249
257
  if (remove) {
250
258
  onLog(`deleting incomplete backup ${json}`)
259
+ jsons.delete(json)
251
260
  await handler.unlink(json)
252
261
  }
253
262
  }
@@ -256,9 +265,12 @@ exports.cleanVm = async function cleanVm(
256
265
  const { vhds } = metadata
257
266
  return Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
258
267
  })()
268
+
269
+ const missingVhds = linkedVhds.filter(_ => !vhds.has(_))
270
+
259
271
  // FIXME: find better approach by keeping as much of the backup as
260
272
  // possible (existing disks) even if one disk is missing
261
- if (linkedVhds.every(_ => vhds.has(_))) {
273
+ if (missingVhds.length === 0) {
262
274
  linkedVhds.forEach(_ => unusedVhds.delete(_))
263
275
 
264
276
  // checking the size of a vhd directory is costly
@@ -277,9 +289,10 @@ exports.cleanVm = async function cleanVm(
277
289
  }
278
290
  }
279
291
  } else {
280
- onLog(`Some VHDs linked to the metadata ${json} are missing`)
292
+ onLog(`Some VHDs linked to the metadata ${json} are missing`, { missingVhds })
281
293
  if (remove) {
282
294
  onLog(`deleting incomplete backup ${json}`)
295
+ jsons.delete(json)
283
296
  await handler.unlink(json)
284
297
  }
285
298
  }
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.16.0",
11
+ "version": "0.17.1",
12
12
  "engines": {
13
13
  "node": ">=14.6"
14
14
  },
@@ -16,11 +16,11 @@
16
16
  "postversion": "npm publish --access public"
17
17
  },
18
18
  "dependencies": {
19
- "@vates/compose": "^2.0.0",
19
+ "@vates/compose": "^2.1.0",
20
20
  "@vates/disposable": "^0.1.1",
21
21
  "@vates/parse-duration": "^0.1.1",
22
22
  "@xen-orchestra/async-map": "^0.1.2",
23
- "@xen-orchestra/fs": "^0.19.1",
23
+ "@xen-orchestra/fs": "^0.19.3",
24
24
  "@xen-orchestra/log": "^0.3.0",
25
25
  "@xen-orchestra/template": "^0.1.0",
26
26
  "compare-versions": "^4.0.1",
@@ -36,11 +36,11 @@
36
36
  "proper-lockfile": "^4.1.2",
37
37
  "pump": "^3.0.0",
38
38
  "uuid": "^8.3.2",
39
- "vhd-lib": "^2.0.0",
39
+ "vhd-lib": "^2.0.4",
40
40
  "yazl": "^2.5.1"
41
41
  },
42
42
  "peerDependencies": {
43
- "@xen-orchestra/xapi": "^0.8.0"
43
+ "@xen-orchestra/xapi": "^0.8.4"
44
44
  },
45
45
  "license": "AGPL-3.0-or-later",
46
46
  "author": {
@@ -21,10 +21,18 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
21
21
  this.#vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
22
22
  }
23
23
 
24
- _cleanVm(options) {
25
- return this._adapter
26
- .cleanVm(this.#vmBackupDir, { ...options, fixMetadata: true, onLog: warn, lock: false })
27
- .catch(warn)
24
+ async _cleanVm(options) {
25
+ try {
26
+ return await this._adapter.cleanVm(this.#vmBackupDir, {
27
+ ...options,
28
+ fixMetadata: true,
29
+ onLog: warn,
30
+ lock: false,
31
+ })
32
+ } catch (error) {
33
+ warn(error)
34
+ return {}
35
+ }
28
36
  }
29
37
 
30
38
  async beforeBackup() {
@@ -43,7 +51,13 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
43
51
  // merge worker only compatible with local remotes
44
52
  const { handler } = this._adapter
45
53
  if (merge && !disableMergeWorker && typeof handler._getRealPath === 'function') {
46
- await handler.outputFile(join(MergeWorker.CLEAN_VM_QUEUE, formatFilenameDate(new Date())), this._backup.vm.uuid)
54
+ const taskFile =
55
+ join(MergeWorker.CLEAN_VM_QUEUE, formatFilenameDate(new Date())) +
56
+ '-' +
57
+ // add a random suffix to avoid collision in case multiple tasks are created at the same second
58
+ Math.random().toString(36).slice(2)
59
+
60
+ await handler.outputFile(taskFile, this._backup.vm.uuid)
47
61
  const remotePath = handler._getRealPath()
48
62
  await MergeWorker.run(remotePath)
49
63
  }