@xen-orchestra/backups 0.16.2 → 0.18.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 { 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 } = {}) {
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
  }
@@ -263,46 +285,18 @@ exports.cleanVm = async function cleanVm(
263
285
  // possible (existing disks) even if one disk is missing
264
286
  if (missingVhds.length === 0) {
265
287
  linkedVhds.forEach(_ => unusedVhds.delete(_))
266
-
267
- // checking the size of a vhd directory is costly
268
- // 1 Http Query per 1000 blocks
269
- // we only check size of all the vhd are VhdFiles
270
-
271
- const shouldComputeSize = linkedVhds.every(vhd => vhd instanceof VhdFile)
272
- if (shouldComputeSize) {
273
- try {
274
- await Disposable.use(Disposable.all(linkedVhds.map(vhdPath => openVhd(handler, vhdPath))), async vhds => {
275
- const sizes = await asyncMap(vhds, vhd => vhd.getSize())
276
- size = sum(sizes)
277
- })
278
- } catch (error) {
279
- onLog(`failed to get size of ${json}`, { error })
280
- }
281
- }
288
+ linkedVhds.forEach(path => {
289
+ vhdsToJSons[path] = json
290
+ })
282
291
  } else {
283
292
  onLog(`Some VHDs linked to the metadata ${json} are missing`, { missingVhds })
284
293
  if (remove) {
285
294
  onLog(`deleting incomplete backup ${json}`)
295
+ jsons.delete(json)
286
296
  await handler.unlink(json)
287
297
  }
288
298
  }
289
299
  }
290
-
291
- const metadataSize = metadata.size
292
- if (size !== undefined && metadataSize !== size) {
293
- onLog(`incorrect size in metadata: ${metadataSize ?? 'none'} instead of ${size}`)
294
-
295
- // don't update if the the stored size is greater than found files,
296
- // it can indicates a problem
297
- if (fixMetadata && (metadataSize === undefined || metadataSize < size)) {
298
- try {
299
- metadata.size = size
300
- await handler.writeFile(json, JSON.stringify(metadata), { flags: 'w' })
301
- } catch (error) {
302
- onLog(`failed to update size in backup metadata ${json}`, { error })
303
- }
304
- }
305
- }
306
300
  })
307
301
 
308
302
  // TODO: parallelize by vm/job/vdi
@@ -361,9 +355,15 @@ exports.cleanVm = async function cleanVm(
361
355
  })
362
356
  }
363
357
 
364
- const doMerge = () => {
365
- const promise = asyncMap(toMerge, async chain => limitedMergeVhdChain(chain, { handler, onLog, remove, merge }))
366
- 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
+ })
367
367
  }
368
368
 
369
369
  await Promise.all([
@@ -388,6 +388,47 @@ exports.cleanVm = async function cleanVm(
388
388
  }),
389
389
  ])
390
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
+
391
432
  return {
392
433
  // boolean whether some VHDs were merged (or should be merged)
393
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.2",
11
+ "version": "0.18.1",
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.2",
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.3",
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))