@xen-orchestra/backups 0.16.1 → 0.18.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/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 { Constants, createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdSynthetic } = require('vhd-lib')
9
+ const { Constants, createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, 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')
@@ -67,10 +68,11 @@ const debounceResourceFactory = factory =>
67
68
  }
68
69
 
69
70
  class RemoteAdapter {
70
- constructor(handler, { debounceResource = res => res, dirMode } = {}) {
71
+ constructor(handler, { debounceResource = res => res, dirMode, vhdDirectoryCompression = 'brotli' } = {}) {
71
72
  this._debounceResource = debounceResource
72
73
  this._dirMode = dirMode
73
74
  this._handler = handler
75
+ this._vhdDirectoryCompression = vhdDirectoryCompression
74
76
  }
75
77
 
76
78
  get handler() {
@@ -190,6 +192,22 @@ class RemoteAdapter {
190
192
  return files
191
193
  }
192
194
 
195
+ // check if we will be allowed to merge a a vhd created in this adapter
196
+ // with the vhd at path `path`
197
+ async isMergeableParent(packedParentUid, path) {
198
+ return await Disposable.use(openVhd(this.handler, path), vhd => {
199
+ // this baseUuid is not linked with this vhd
200
+ if (!vhd.footer.uuid.equals(packedParentUid)) {
201
+ return false
202
+ }
203
+
204
+ const isVhdDirectory = vhd instanceof VhdDirectory
205
+ return isVhdDirectory
206
+ ? this.#useVhdDirectory && this.#getCompressionType() === vhd.compressionType
207
+ : !this.#useVhdDirectory
208
+ })
209
+ }
210
+
193
211
  fetchPartitionFiles(diskId, partitionId, paths) {
194
212
  const { promise, reject, resolve } = pDefer()
195
213
  Disposable.use(
@@ -243,17 +261,34 @@ class RemoteAdapter {
243
261
  )
244
262
  }
245
263
 
246
- async deleteVmBackup(filename) {
247
- const metadata = JSON.parse(String(await this._handler.readFile(filename)))
248
- metadata._filename = filename
264
+ deleteVmBackup(file) {
265
+ return this.deleteVmBackups([file])
266
+ }
267
+
268
+ async deleteVmBackups(files) {
269
+ const { delta, full, ...others } = groupBy(await asyncMap(files, file => this.readVmBackupMetadata(file)), 'mode')
249
270
 
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}`)
271
+ const unsupportedModes = Object.keys(others)
272
+ if (unsupportedModes.length !== 0) {
273
+ throw new Error('no deleter for backup modes: ' + unsupportedModes.join(', '))
256
274
  }
275
+
276
+ await Promise.all([
277
+ delta !== undefined && this.deleteDeltaVmBackups(delta),
278
+ full !== undefined && this.deleteFullVmBackups(full),
279
+ ])
280
+ }
281
+
282
+ #getCompressionType() {
283
+ return this._vhdDirectoryCompression
284
+ }
285
+
286
+ #useVhdDirectory() {
287
+ return this.handler.type === 's3'
288
+ }
289
+
290
+ #useAlias() {
291
+ return this.#useVhdDirectory()
257
292
  }
258
293
 
259
294
  getDisk = Disposable.factory(this.getDisk)
@@ -312,13 +347,10 @@ class RemoteAdapter {
312
347
  return yield this._getPartition(devicePath, await this._findPartition(devicePath, partitionId))
313
348
  }
314
349
 
315
- // this function will be the one where we plug the logic of the storage format by fs type/user settings
316
-
317
- // if the file is named .vhd => vhd
318
- // if the file is named alias.vhd => alias to a vhd
350
+ // if we use alias on this remote, we have to name the file alias.vhd
319
351
  getVhdFileName(baseName) {
320
- if (this._handler.type === 's3') {
321
- return `${baseName}.alias.vhd` // we want an alias to a vhddirectory
352
+ if (this.#useAlias()) {
353
+ return `${baseName}.alias.vhd`
322
354
  }
323
355
  return `${baseName}.vhd`
324
356
  }
@@ -470,10 +502,11 @@ class RemoteAdapter {
470
502
  async writeVhd(path, input, { checksum = true, validator = noop } = {}) {
471
503
  const handler = this._handler
472
504
 
473
- if (path.endsWith('.alias.vhd')) {
505
+ if (this.#useVhdDirectory()) {
474
506
  const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
475
507
  await createVhdDirectoryFromStream(handler, dataPath, input, {
476
508
  concurrency: 16,
509
+ compression: this.#getCompressionType(),
477
510
  async validator() {
478
511
  await input.task
479
512
  return validator.apply(this, arguments)
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
@@ -354,6 +359,11 @@ exports.VmBackup = class VmBackup {
354
359
  false
355
360
  )
356
361
 
362
+ if (presentBaseVdis.size === 0) {
363
+ debug('no base VM found')
364
+ return
365
+ }
366
+
357
367
  const fullVdisRequired = new Set()
358
368
  baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
359
369
  if (presentBaseVdis.has(baseUuid)) {
package/_backupWorker.js CHANGED
@@ -70,6 +70,7 @@ class BackupWorker {
70
70
  yield new RemoteAdapter(handler, {
71
71
  debounceResource: this.debounceResource,
72
72
  dirMode: this.#config.dirMode,
73
+ vhdDirectoryCompression: this.#config.vhdDirectoryCompression,
73
74
  })
74
75
  } finally {
75
76
  await handler.forget()
package/_cleanVm.js CHANGED
@@ -10,6 +10,24 @@ const { limitConcurrency } = require('limit-concurrency-decorator')
10
10
  const { Task } = require('./Task.js')
11
11
  const { Disposable } = require('promise-toolbox')
12
12
 
13
+ // checking the size of a vhd directory is costly
14
+ // 1 Http Query per 1000 blocks
15
+ // we only check size of all the vhd are VhdFiles
16
+ function shouldComputeVhdsSize(vhds) {
17
+ return vhds.every(vhd => vhd instanceof VhdFile)
18
+ }
19
+
20
+ const computeVhdsSize = (handler, vhdPaths) =>
21
+ Disposable.use(
22
+ vhdPaths.map(vhdPath => openVhd(handler, vhdPath)),
23
+ async vhds => {
24
+ if (shouldComputeVhdsSize(vhds)) {
25
+ const sizes = await asyncMap(vhds, vhd => vhd.getSize())
26
+ return sum(sizes)
27
+ }
28
+ }
29
+ )
30
+
13
31
  // chain is an array of VHDs from child to parent
14
32
  //
15
33
  // the whole chain will be merged into parent, parent will be renamed to child
@@ -130,6 +148,7 @@ exports.cleanVm = async function cleanVm(
130
148
  const handler = this._handler
131
149
 
132
150
  const vhds = new Set()
151
+ const vhdsToJSons = new Set()
133
152
  const vhdParents = { __proto__: null }
134
153
  const vhdChildren = { __proto__: null }
135
154
 
@@ -202,7 +221,7 @@ exports.cleanVm = async function cleanVm(
202
221
  await Promise.all(deletions)
203
222
  }
204
223
 
205
- const jsons = []
224
+ const jsons = new Set()
206
225
  const xvas = new Set()
207
226
  const xvaSums = []
208
227
  const entries = await handler.list(vmDir, {
@@ -210,7 +229,7 @@ exports.cleanVm = async function cleanVm(
210
229
  })
211
230
  entries.forEach(path => {
212
231
  if (isMetadataFile(path)) {
213
- jsons.push(path)
232
+ jsons.add(path)
214
233
  } else if (isXvaFile(path)) {
215
234
  xvas.add(path)
216
235
  } else if (isXvaSumFile(path)) {
@@ -232,22 +251,25 @@ exports.cleanVm = async function cleanVm(
232
251
  // compile the list of unused XVAs and VHDs, and remove backup metadata which
233
252
  // reference a missing XVA/VHD
234
253
  await asyncMap(jsons, async json => {
235
- const metadata = JSON.parse(await handler.readFile(json))
254
+ let metadata
255
+ try {
256
+ metadata = JSON.parse(await handler.readFile(json))
257
+ } catch (error) {
258
+ onLog(`failed to read metadata file ${json}`, { error })
259
+ jsons.delete(json)
260
+ return
261
+ }
262
+
236
263
  const { mode } = metadata
237
- let size
238
264
  if (mode === 'full') {
239
265
  const linkedXva = resolve('/', vmDir, metadata.xva)
240
-
241
266
  if (xvas.has(linkedXva)) {
242
267
  unusedXvas.delete(linkedXva)
243
-
244
- size = await handler.getSize(linkedXva).catch(error => {
245
- onLog(`failed to get size of ${json}`, { error })
246
- })
247
268
  } else {
248
269
  onLog(`the XVA linked to the metadata ${json} is missing`)
249
270
  if (remove) {
250
271
  onLog(`deleting incomplete backup ${json}`)
272
+ jsons.delete(json)
251
273
  await handler.unlink(json)
252
274
  }
253
275
  }
@@ -256,50 +278,25 @@ exports.cleanVm = async function cleanVm(
256
278
  const { vhds } = metadata
257
279
  return Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
258
280
  })()
281
+
282
+ const missingVhds = linkedVhds.filter(_ => !vhds.has(_))
283
+
259
284
  // FIXME: find better approach by keeping as much of the backup as
260
285
  // possible (existing disks) even if one disk is missing
261
- if (linkedVhds.every(_ => vhds.has(_))) {
286
+ if (missingVhds.length === 0) {
262
287
  linkedVhds.forEach(_ => unusedVhds.delete(_))
263
-
264
- // checking the size of a vhd directory is costly
265
- // 1 Http Query per 1000 blocks
266
- // we only check size of all the vhd are VhdFiles
267
-
268
- const shouldComputeSize = linkedVhds.every(vhd => vhd instanceof VhdFile)
269
- if (shouldComputeSize) {
270
- try {
271
- await Disposable.use(Disposable.all(linkedVhds.map(vhdPath => openVhd(handler, vhdPath))), async vhds => {
272
- const sizes = await asyncMap(vhds, vhd => vhd.getSize())
273
- size = sum(sizes)
274
- })
275
- } catch (error) {
276
- onLog(`failed to get size of ${json}`, { error })
277
- }
278
- }
288
+ linkedVhds.forEach(path => {
289
+ vhdsToJSons[path] = json
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
  }
286
299
  }
287
-
288
- const metadataSize = metadata.size
289
- if (size !== undefined && metadataSize !== size) {
290
- onLog(`incorrect size in metadata: ${metadataSize ?? 'none'} instead of ${size}`)
291
-
292
- // don't update if the the stored size is greater than found files,
293
- // it can indicates a problem
294
- if (fixMetadata && (metadataSize === undefined || metadataSize < size)) {
295
- try {
296
- metadata.size = size
297
- await handler.writeFile(json, JSON.stringify(metadata), { flags: 'w' })
298
- } catch (error) {
299
- onLog(`failed to update size in backup metadata ${json}`, { error })
300
- }
301
- }
302
- }
303
300
  })
304
301
 
305
302
  // TODO: parallelize by vm/job/vdi
@@ -358,9 +355,15 @@ exports.cleanVm = async function cleanVm(
358
355
  })
359
356
  }
360
357
 
361
- const doMerge = () => {
362
- const promise = asyncMap(toMerge, async chain => limitedMergeVhdChain(chain, { handler, onLog, remove, merge }))
363
- return merge ? promise.then(sizes => ({ size: sum(sizes) })) : promise
358
+ const metadataWithMergedVhd = {}
359
+ const doMerge = async () => {
360
+ await asyncMap(toMerge, async chain => {
361
+ const merged = await limitedMergeVhdChain(chain, { handler, onLog, remove, merge })
362
+ if (merged !== undefined) {
363
+ const metadataPath = vhdsToJSons[chain[0]] // all the chain should have the same metada file
364
+ metadataWithMergedVhd[metadataPath] = true
365
+ }
366
+ })
364
367
  }
365
368
 
366
369
  await Promise.all([
@@ -385,6 +388,47 @@ exports.cleanVm = async function cleanVm(
385
388
  }),
386
389
  ])
387
390
 
391
+ // update size for delta metadata with merged VHD
392
+ // check for the other that the size is the same as the real file size
393
+
394
+ await asyncMap(jsons, async metadataPath => {
395
+ const metadata = JSON.parse(await handler.readFile(metadataPath))
396
+
397
+ let fileSystemSize
398
+ const merged = metadataWithMergedVhd[metadataPath] !== undefined
399
+
400
+ const { mode, size, vhds, xva } = metadata
401
+
402
+ try {
403
+ if (mode === 'full') {
404
+ // a full backup : check size
405
+ const linkedXva = resolve('/', vmDir, xva)
406
+ fileSystemSize = await handler.getSize(linkedXva)
407
+ } else if (mode === 'delta') {
408
+ const linkedVhds = Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
409
+ fileSystemSize = await computeVhdsSize(handler, linkedVhds)
410
+
411
+ // don't warn if the size has changed after a merge
412
+ if (!merged && fileSystemSize !== size) {
413
+ onLog(`incorrect size in metadata: ${size ?? 'none'} instead of ${fileSystemSize}`)
414
+ }
415
+ }
416
+ } catch (error) {
417
+ onLog(`failed to get size of ${metadataPath}`, { error })
418
+ return
419
+ }
420
+
421
+ // systematically update size after a merge
422
+ if ((merged || fixMetadata) && size !== fileSystemSize) {
423
+ metadata.size = fileSystemSize
424
+ try {
425
+ await handler.writeFile(metadataPath, JSON.stringify(metadata), { flags: 'w' })
426
+ } catch (error) {
427
+ onLog(`failed to update size in backup metadata ${metadataPath} after merge`, { error })
428
+ }
429
+ }
430
+ })
431
+
388
432
  return {
389
433
  // boolean whether some VHDs were merged (or should be merged)
390
434
  merge: toMerge.length !== 0,
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.1",
11
+ "version": "0.18.0",
12
12
  "engines": {
13
13
  "node": ">=14.6"
14
14
  },
@@ -20,7 +20,7 @@
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.2",
39
+ "vhd-lib": "^2.1.0",
40
40
  "yazl": "^2.5.1"
41
41
  },
42
42
  "peerDependencies": {
43
- "@xen-orchestra/xapi": "^0.8.4"
43
+ "@xen-orchestra/xapi": "^0.8.5"
44
44
  },
45
45
  "license": "AGPL-3.0-or-later",
46
46
  "author": {
@@ -24,6 +24,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
24
24
  async checkBaseVdis(baseUuidToSrcVdi) {
25
25
  const { handler } = this._adapter
26
26
  const backup = this._backup
27
+ const adapter = this._adapter
27
28
 
28
29
  const backupDir = getVmBackupDir(backup.vm.uuid)
29
30
  const vdisDir = `${backupDir}/vdis/${backup.job.id}`
@@ -35,13 +36,11 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
35
36
  filter: _ => _[0] !== '.' && _.endsWith('.vhd'),
36
37
  prependDir: true,
37
38
  })
39
+ const packedBaseUuid = packUuid(baseUuid)
38
40
  await asyncMap(vhds, async path => {
39
41
  try {
40
42
  await checkVhdChain(handler, path)
41
- await Disposable.use(
42
- openVhd(handler, path),
43
- vhd => (found = found || vhd.footer.uuid.equals(packUuid(baseUuid)))
44
- )
43
+ found = found || (await adapter.isMergeableParent(packedBaseUuid, path))
45
44
  } catch (error) {
46
45
  warn('checkBaseVdis', { error })
47
46
  await ignoreErrors.call(VhdAbstract.unlink(handler, path))
@@ -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
  }