@xen-orchestra/backups 0.13.0 → 0.16.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,19 +3,19 @@ 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 pump = require('pump')
7
- const { basename, dirname, join, normalize, resolve } = require('path')
6
+ const { dirname, join, normalize, resolve } = require('path')
8
7
  const { createLogger } = require('@xen-orchestra/log')
9
- const { createSyntheticStream, mergeVhd, default: Vhd } = require('vhd-lib')
8
+ const { VhdAbstract, createVhdDirectoryFromStream } = require('vhd-lib')
10
9
  const { deduped } = require('@vates/disposable/deduped.js')
11
10
  const { execFile } = require('child_process')
12
11
  const { readdir, stat } = require('fs-extra')
12
+ const { v4: uuidv4 } = require('uuid')
13
13
  const { ZipFile } = require('yazl')
14
14
 
15
15
  const { BACKUP_DIR } = require('./_getVmBackupDir.js')
16
16
  const { cleanVm } = require('./_cleanVm.js')
17
17
  const { getTmpDir } = require('./_getTmpDir.js')
18
- const { isMetadataFile, isVhdFile } = require('./_backupType.js')
18
+ const { isMetadataFile } = require('./_backupType.js')
19
19
  const { isValidXva } = require('./_isValidXva.js')
20
20
  const { listPartitions, LVM_PARTITION_TYPE } = require('./_listPartitions.js')
21
21
  const { lvs, pvs } = require('./_lvm.js')
@@ -77,48 +77,6 @@ class RemoteAdapter {
77
77
  return this._handler
78
78
  }
79
79
 
80
- async _deleteVhd(path) {
81
- const handler = this._handler
82
- const vhds = await asyncMapSettled(
83
- await handler.list(dirname(path), {
84
- filter: isVhdFile,
85
- prependDir: true,
86
- }),
87
- async path => {
88
- try {
89
- const vhd = new Vhd(handler, path)
90
- await vhd.readHeaderAndFooter()
91
- return {
92
- footer: vhd.footer,
93
- header: vhd.header,
94
- path,
95
- }
96
- } catch (error) {
97
- // Do not fail on corrupted VHDs (usually uncleaned temporary files),
98
- // they are probably inconsequent to the backup process and should not
99
- // fail it.
100
- warn(`BackupNg#_deleteVhd ${path}`, { error })
101
- }
102
- }
103
- )
104
- const base = basename(path)
105
- const child = vhds.find(_ => _ !== undefined && _.header.parentUnicodeName === base)
106
- if (child === undefined) {
107
- await handler.unlink(path)
108
- return 0
109
- }
110
-
111
- try {
112
- const childPath = child.path
113
- const mergedDataSize = await mergeVhd(handler, path, handler, childPath)
114
- await handler.rename(path, childPath)
115
- return mergedDataSize
116
- } catch (error) {
117
- handler.unlink(path).catch(warn)
118
- throw error
119
- }
120
- }
121
-
122
80
  async _findPartition(devicePath, partitionId) {
123
81
  const partitions = await listPartitions(devicePath)
124
82
  const partition = partitions.find(_ => _.id === partitionId)
@@ -253,16 +211,9 @@ class RemoteAdapter {
253
211
 
254
212
  async deleteDeltaVmBackups(backups) {
255
213
  const handler = this._handler
256
- let mergedDataSize = 0
257
- await asyncMapSettled(backups, ({ _filename, vhds }) =>
258
- Promise.all([
259
- handler.unlink(_filename),
260
- asyncMap(Object.values(vhds), async _ => {
261
- mergedDataSize += await this._deleteVhd(resolveRelativeFromFile(_filename, _))
262
- }),
263
- ])
264
- )
265
- return mergedDataSize
214
+
215
+ // unused VHDs will be detected by `cleanVm`
216
+ await asyncMapSettled(backups, ({ _filename }) => VhdAbstract.unlink(handler, _filename))
266
217
  }
267
218
 
268
219
  async deleteMetadataBackup(backupId) {
@@ -361,6 +312,17 @@ class RemoteAdapter {
361
312
  return yield this._getPartition(devicePath, await this._findPartition(devicePath, partitionId))
362
313
  }
363
314
 
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
319
+ getVhdFileName(baseName) {
320
+ if (this._handler.type === 's3') {
321
+ return `${baseName}.alias.vhd` // we want an alias to a vhddirectory
322
+ }
323
+ return `${baseName}.vhd`
324
+ }
325
+
364
326
  async listAllVmBackups() {
365
327
  const handler = this._handler
366
328
 
@@ -505,6 +467,24 @@ class RemoteAdapter {
505
467
  return backups.sort(compareTimestamp)
506
468
  }
507
469
 
470
+ async writeVhd(path, input, { checksum = true, validator = noop } = {}) {
471
+ const handler = this._handler
472
+ let dataPath = path
473
+
474
+ if (path.endsWith('.alias.vhd')) {
475
+ await createVhdDirectoryFromStream(handler, `${dirname(path)}/data/${uuidv4()}.vhd`, input, {
476
+ concurrency: 16,
477
+ async validator() {
478
+ await input.task
479
+ return validator.apply(this, arguments)
480
+ },
481
+ })
482
+ await VhdAbstract.createAlias(handler, path, dataPath)
483
+ } else {
484
+ await this.outputStream(dataPath, input, { checksum, validator })
485
+ }
486
+ }
487
+
508
488
  async outputStream(path, input, { checksum = true, validator = noop } = {}) {
509
489
  await this._handler.outputStream(path, input, {
510
490
  checksum,
@@ -516,6 +496,21 @@ class RemoteAdapter {
516
496
  })
517
497
  }
518
498
 
499
+ async _createSyntheticStream(handler, paths) {
500
+ // I don't want the vhds to be disposed on return
501
+ // but only when the stream is done ( or failed )
502
+ const disposables = await Disposable.all(paths.map(path => openVhd(handler, path)))
503
+ const vhds = disposables.value
504
+ const synthetic = new VhdSynthetic(vhds)
505
+ await synthetic.readHeaderAndFooter()
506
+ await synthetic.readBlockAllocationTable()
507
+ const stream = await synthetic.stream()
508
+ stream.on('end', () => disposables.dispose())
509
+ stream.on('close', () => disposables.dispose())
510
+ stream.on('error', () => disposables.dispose())
511
+ return stream
512
+ }
513
+
519
514
  async readDeltaVmBackup(metadata) {
520
515
  const handler = this._handler
521
516
  const { vbds, vdis, vhds, vifs, vm } = metadata
@@ -523,7 +518,7 @@ class RemoteAdapter {
523
518
 
524
519
  const streams = {}
525
520
  await asyncMapSettled(Object.keys(vdis), async id => {
526
- streams[`${id}.vhd`] = await createSyntheticStream(handler, join(dir, vhds[id]))
521
+ streams[`${id}.vhd`] = await this._createSyntheticStream(handler, join(dir, vhds[id]))
527
522
  })
528
523
 
529
524
  return {
package/_VmBackup.js CHANGED
@@ -1,9 +1,10 @@
1
1
  const assert = require('assert')
2
2
  const findLast = require('lodash/findLast.js')
3
+ const groupBy = require('lodash/groupBy.js')
3
4
  const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
4
5
  const keyBy = require('lodash/keyBy.js')
5
6
  const mapValues = require('lodash/mapValues.js')
6
- const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
7
+ const { asyncMap } = require('@xen-orchestra/async-map')
7
8
  const { createLogger } = require('@xen-orchestra/log')
8
9
  const { defer } = require('golike-defer')
9
10
  const { formatDateTime } = require('@xen-orchestra/xapi')
@@ -284,17 +285,28 @@ exports.VmBackup = class VmBackup {
284
285
  }
285
286
 
286
287
  async _removeUnusedSnapshots() {
287
- // TODO: handle all schedules (no longer existing schedules default to 0 retention)
288
-
289
- const { scheduleId } = this
290
- const scheduleSnapshots = this._jobSnapshots.filter(_ => _.other_config['xo:backup:schedule'] === scheduleId)
291
-
288
+ const jobSettings = this.job.settings
292
289
  const baseVmRef = this._baseVm?.$ref
290
+ const { config } = this
291
+ const baseSettings = {
292
+ ...config.defaultSettings,
293
+ ...config.metadata.defaultSettings,
294
+ ...jobSettings[''],
295
+ }
296
+
297
+ const snapshotsPerSchedule = groupBy(this._jobSnapshots, _ => _.other_config['xo:backup:schedule'])
293
298
  const xapi = this._xapi
294
- await asyncMap(getOldEntries(this._settings.snapshotRetention, scheduleSnapshots), ({ $ref }) => {
295
- if ($ref !== baseVmRef) {
296
- return xapi.VM_destroy($ref)
299
+ await asyncMap(Object.entries(snapshotsPerSchedule), ([scheduleId, snapshots]) => {
300
+ const settings = {
301
+ ...baseSettings,
302
+ ...jobSettings[scheduleId],
303
+ ...jobSettings[this.vm.uuid],
297
304
  }
305
+ return asyncMap(getOldEntries(settings.snapshotRetention, snapshots), ({ $ref }) => {
306
+ if ($ref !== baseVmRef) {
307
+ return xapi.VM_destroy($ref)
308
+ }
309
+ })
298
310
  })
299
311
  }
300
312
 
@@ -303,12 +315,14 @@ exports.VmBackup = class VmBackup {
303
315
 
304
316
  let baseVm = findLast(this._jobSnapshots, _ => 'xo:backup:exported' in _.other_config)
305
317
  if (baseVm === undefined) {
318
+ debug('no base VM found')
306
319
  return
307
320
  }
308
321
 
309
322
  const fullInterval = this._settings.fullInterval
310
323
  const deltaChainLength = +(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1
311
324
  if (!(fullInterval === 0 || fullInterval > deltaChainLength)) {
325
+ debug('not using base VM becaust fullInterval reached')
312
326
  return
313
327
  }
314
328
 
@@ -323,6 +337,10 @@ exports.VmBackup = class VmBackup {
323
337
  const srcVdi = srcVdis[snapshotOf]
324
338
  if (srcVdi !== undefined) {
325
339
  baseUuidToSrcVdi.set(await xapi.getField('VDI', baseRef, 'uuid'), srcVdi)
340
+ } else {
341
+ debug('no base VDI found', {
342
+ vdi: srcVdi.uuid,
343
+ })
326
344
  }
327
345
  })
328
346
 
@@ -335,7 +353,16 @@ exports.VmBackup = class VmBackup {
335
353
 
336
354
  const fullVdisRequired = new Set()
337
355
  baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
338
- if (!presentBaseVdis.has(baseUuid)) {
356
+ if (presentBaseVdis.has(baseUuid)) {
357
+ debug('found base VDI', {
358
+ base: baseUuid,
359
+ vdi: srcVdi.uuid,
360
+ })
361
+ } else {
362
+ debug('missing base VDI', {
363
+ base: baseUuid,
364
+ vdi: srcVdi.uuid,
365
+ })
339
366
  fullVdisRequired.add(srcVdi.uuid)
340
367
  }
341
368
  })
package/_cleanVm.js CHANGED
@@ -1,17 +1,20 @@
1
1
  const assert = require('assert')
2
2
  const sum = require('lodash/sum')
3
3
  const { asyncMap } = require('@xen-orchestra/async-map')
4
- const { default: Vhd, mergeVhd } = require('vhd-lib')
4
+ const { Constants, mergeVhd, openVhd, VhdAbstract, VhdFile } = require('vhd-lib')
5
5
  const { dirname, resolve } = require('path')
6
- const { DISK_TYPE_DIFFERENCING } = require('vhd-lib/dist/_constants.js')
6
+ const { DISK_TYPES } = Constants
7
7
  const { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } = require('./_backupType.js')
8
8
  const { limitConcurrency } = require('limit-concurrency-decorator')
9
9
 
10
+ const { Task } = require('./Task.js')
11
+ const { Disposable } = require('promise-toolbox')
12
+
10
13
  // chain is an array of VHDs from child to parent
11
14
  //
12
15
  // the whole chain will be merged into parent, parent will be renamed to child
13
16
  // and all the others will deleted
14
- const mergeVhdChain = limitConcurrency(1)(async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
17
+ async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
15
18
  assert(chain.length >= 2)
16
19
 
17
20
  let child = chain[0]
@@ -44,7 +47,7 @@ const mergeVhdChain = limitConcurrency(1)(async function mergeVhdChain(chain, {
44
47
  }
45
48
  }, 10e3)
46
49
 
47
- await mergeVhd(
50
+ const mergedSize = await mergeVhd(
48
51
  handler,
49
52
  parent,
50
53
  handler,
@@ -63,17 +66,19 @@ const mergeVhdChain = limitConcurrency(1)(async function mergeVhdChain(chain, {
63
66
  clearInterval(handle)
64
67
 
65
68
  await Promise.all([
66
- handler.rename(parent, child),
69
+ VhdAbstract.rename(handler, parent, child),
67
70
  asyncMap(children.slice(0, -1), child => {
68
71
  onLog(`the VHD ${child} is unused`)
69
72
  if (remove) {
70
73
  onLog(`deleting unused VHD ${child}`)
71
- return handler.unlink(child)
74
+ return VhdAbstract.unlink(handler, child)
72
75
  }
73
76
  }),
74
77
  ])
78
+
79
+ return mergedSize
75
80
  }
76
- })
81
+ }
77
82
 
78
83
  const noop = Function.prototype
79
84
 
@@ -114,7 +119,14 @@ const listVhds = async (handler, vmDir) => {
114
119
  return { vhds, interruptedVhds }
115
120
  }
116
121
 
117
- exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, onLog = noop }) {
122
+ const defaultMergeLimiter = limitConcurrency(1)
123
+
124
+ exports.cleanVm = async function cleanVm(
125
+ vmDir,
126
+ { fixMetadata, remove, merge, mergeLimiter = defaultMergeLimiter, onLog = noop }
127
+ ) {
128
+ const limitedMergeVhdChain = mergeLimiter(mergeVhdChain)
129
+
118
130
  const handler = this._handler
119
131
 
120
132
  const vhds = new Set()
@@ -126,53 +138,55 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
126
138
  // remove broken VHDs
127
139
  await asyncMap(vhdsList.vhds, async path => {
128
140
  try {
129
- const vhd = new Vhd(handler, path)
130
- await vhd.readHeaderAndFooter(!vhdsList.interruptedVhds.has(path))
131
- vhds.add(path)
132
- if (vhd.footer.diskType === DISK_TYPE_DIFFERENCING) {
133
- const parent = resolve('/', dirname(path), vhd.header.parentUnicodeName)
134
- vhdParents[path] = parent
135
- if (parent in vhdChildren) {
136
- const error = new Error('this script does not support multiple VHD children')
137
- error.parent = parent
138
- error.child1 = vhdChildren[parent]
139
- error.child2 = path
140
- throw error // should we throw?
141
+ await Disposable.use(openVhd(handler, path, { checkSecondFooter: !vhdsList.interruptedVhds.has(path) }), vhd => {
142
+ vhds.add(path)
143
+ if (vhd.footer.diskType === DISK_TYPES.DIFFERENCING) {
144
+ const parent = resolve('/', dirname(path), vhd.header.parentUnicodeName)
145
+ vhdParents[path] = parent
146
+ if (parent in vhdChildren) {
147
+ const error = new Error('this script does not support multiple VHD children')
148
+ error.parent = parent
149
+ error.child1 = vhdChildren[parent]
150
+ error.child2 = path
151
+ throw error // should we throw?
152
+ }
153
+ vhdChildren[parent] = path
141
154
  }
142
- vhdChildren[parent] = path
143
- }
155
+ })
144
156
  } catch (error) {
145
157
  onLog(`error while checking the VHD with path ${path}`, { error })
146
158
  if (error?.code === 'ERR_ASSERTION' && remove) {
147
159
  onLog(`deleting broken ${path}`)
148
- await handler.unlink(path)
160
+ return VhdAbstract.unlink(handler, path)
149
161
  }
150
162
  }
151
163
  })
152
164
 
165
+ // @todo : add check for data folder of alias not referenced in a valid alias
166
+
153
167
  // remove VHDs with missing ancestors
154
168
  {
155
169
  const deletions = []
156
170
 
157
171
  // return true if the VHD has been deleted or is missing
158
- const deleteIfOrphan = vhd => {
159
- const parent = vhdParents[vhd]
172
+ const deleteIfOrphan = vhdPath => {
173
+ const parent = vhdParents[vhdPath]
160
174
  if (parent === undefined) {
161
175
  return
162
176
  }
163
177
 
164
178
  // no longer needs to be checked
165
- delete vhdParents[vhd]
179
+ delete vhdParents[vhdPath]
166
180
 
167
181
  deleteIfOrphan(parent)
168
182
 
169
183
  if (!vhds.has(parent)) {
170
- vhds.delete(vhd)
184
+ vhds.delete(vhdPath)
171
185
 
172
- onLog(`the parent ${parent} of the VHD ${vhd} is missing`)
186
+ onLog(`the parent ${parent} of the VHD ${vhdPath} is missing`)
173
187
  if (remove) {
174
- onLog(`deleting orphan VHD ${vhd}`)
175
- deletions.push(handler.unlink(vhd))
188
+ onLog(`deleting orphan VHD ${vhdPath}`)
189
+ deletions.push(VhdAbstract.unlink(handler, vhdPath))
176
190
  }
177
191
  }
178
192
  }
@@ -242,15 +256,26 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
242
256
  const { vhds } = metadata
243
257
  return Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
244
258
  })()
245
-
246
259
  // FIXME: find better approach by keeping as much of the backup as
247
260
  // possible (existing disks) even if one disk is missing
248
261
  if (linkedVhds.every(_ => vhds.has(_))) {
249
262
  linkedVhds.forEach(_ => unusedVhds.delete(_))
250
263
 
251
- size = await asyncMap(linkedVhds, vhd => handler.getSize(vhd)).then(sum, error => {
252
- onLog(`failed to get size of ${json}`, { error })
253
- })
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
+ }
254
279
  } else {
255
280
  onLog(`Some VHDs linked to the metadata ${json} are missing`)
256
281
  if (remove) {
@@ -279,6 +304,7 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
279
304
 
280
305
  // TODO: parallelize by vm/job/vdi
281
306
  const unusedVhdsDeletion = []
307
+ const toMerge = []
282
308
  {
283
309
  // VHD chains (as list from child to ancestor) to merge indexed by last
284
310
  // ancestor
@@ -312,7 +338,7 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
312
338
  onLog(`the VHD ${vhd} is unused`)
313
339
  if (remove) {
314
340
  onLog(`deleting unused VHD ${vhd}`)
315
- unusedVhdsDeletion.push(handler.unlink(vhd))
341
+ unusedVhdsDeletion.push(VhdAbstract.unlink(handler, vhd))
316
342
  }
317
343
  }
318
344
 
@@ -321,22 +347,25 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
321
347
  })
322
348
 
323
349
  // merge interrupted VHDs
324
- if (merge) {
325
- vhdsList.interruptedVhds.forEach(parent => {
326
- vhdChainsToMerge[parent] = [vhdChildren[parent], parent]
327
- })
328
- }
350
+ vhdsList.interruptedVhds.forEach(parent => {
351
+ vhdChainsToMerge[parent] = [vhdChildren[parent], parent]
352
+ })
329
353
 
330
- Object.keys(vhdChainsToMerge).forEach(key => {
331
- const chain = vhdChainsToMerge[key]
354
+ Object.values(vhdChainsToMerge).forEach(chain => {
332
355
  if (chain !== undefined) {
333
- unusedVhdsDeletion.push(mergeVhdChain(chain, { handler, onLog, remove, merge }))
356
+ toMerge.push(chain)
334
357
  }
335
358
  })
336
359
  }
337
360
 
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
364
+ }
365
+
338
366
  await Promise.all([
339
367
  ...unusedVhdsDeletion,
368
+ toMerge.length !== 0 && (merge ? Task.run({ name: 'merge' }, doMerge) : doMerge()),
340
369
  asyncMap(unusedXvas, path => {
341
370
  onLog(`the XVA ${path} is unused`)
342
371
  if (remove) {
@@ -355,4 +384,9 @@ exports.cleanVm = async function cleanVm(vmDir, { fixMetadata, remove, merge, on
355
384
  }
356
385
  }),
357
386
  ])
387
+
388
+ return {
389
+ // boolean whether some VHDs were merged (or should be merged)
390
+ merge: toMerge.length !== 0,
391
+ }
358
392
  }
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { catchGlobalErrors } = require('@xen-orchestra/log/configure.js')
4
+ const { createLogger } = require('@xen-orchestra/log')
5
+ const { getSyncedHandler } = require('@xen-orchestra/fs')
6
+ const { join } = require('path')
7
+ const Disposable = require('promise-toolbox/Disposable')
8
+ const min = require('lodash/min')
9
+
10
+ const { getVmBackupDir } = require('../_getVmBackupDir.js')
11
+ const { RemoteAdapter } = require('../RemoteAdapter.js')
12
+
13
+ const { CLEAN_VM_QUEUE } = require('./index.js')
14
+
15
+ // -------------------------------------------------------------------
16
+
17
+ catchGlobalErrors(createLogger('xo:backups:mergeWorker'))
18
+
19
+ const { fatal, info, warn } = createLogger('xo:backups:mergeWorker')
20
+
21
+ // -------------------------------------------------------------------
22
+
23
+ const main = Disposable.wrap(async function* main(args) {
24
+ const handler = yield getSyncedHandler({ url: 'file://' + process.cwd() })
25
+
26
+ yield handler.lock(CLEAN_VM_QUEUE)
27
+
28
+ const adapter = new RemoteAdapter(handler)
29
+
30
+ const listRetry = async () => {
31
+ const timeoutResolver = resolve => setTimeout(resolve, 10e3)
32
+ for (let i = 0; i < 10; ++i) {
33
+ const entries = await handler.list(CLEAN_VM_QUEUE)
34
+ if (entries.length !== 0) {
35
+ return entries
36
+ }
37
+ await new Promise(timeoutResolver)
38
+ }
39
+ }
40
+
41
+ let taskFiles
42
+ while ((taskFiles = await listRetry()) !== undefined) {
43
+ const taskFileBasename = min(taskFiles)
44
+ const taskFile = join(CLEAN_VM_QUEUE, '_' + taskFileBasename)
45
+
46
+ // move this task to the end
47
+ await handler.rename(join(CLEAN_VM_QUEUE, taskFileBasename), taskFile)
48
+ try {
49
+ const vmDir = getVmBackupDir(String(await handler.readFile(taskFile)))
50
+ await adapter.cleanVm(vmDir, { merge: true, onLog: info, remove: true })
51
+
52
+ handler.unlink(taskFile).catch(error => warn('deleting task failure', { error }))
53
+ } catch (error) {
54
+ warn('failure handling task', { error })
55
+ }
56
+ }
57
+ })
58
+
59
+ info('starting')
60
+ main(process.argv.slice(2)).then(
61
+ () => {
62
+ info('bye :-)')
63
+ },
64
+ error => {
65
+ fatal(error)
66
+
67
+ process.exit(1)
68
+ }
69
+ )
@@ -0,0 +1,25 @@
1
+ const { join, resolve } = require('path')
2
+ const { spawn } = require('child_process')
3
+ const { check } = require('proper-lockfile')
4
+
5
+ const CLEAN_VM_QUEUE = (exports.CLEAN_VM_QUEUE = '/xo-vm-backups/.queue/clean-vm/')
6
+
7
+ const CLI_PATH = resolve(__dirname, 'cli.js')
8
+ exports.run = async function runMergeWorker(remotePath) {
9
+ try {
10
+ // TODO: find a way to pass the acquire the lock and then pass it down the worker
11
+ if (await check(join(remotePath, CLEAN_VM_QUEUE))) {
12
+ // already locked, don't start another worker
13
+ return
14
+ }
15
+
16
+ spawn(CLI_PATH, {
17
+ cwd: remotePath,
18
+ detached: true,
19
+ stdio: 'inherit',
20
+ }).unref()
21
+ } catch (error) {
22
+ // we usually don't want to throw if the merge worker failed to start
23
+ return error
24
+ }
25
+ }
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.13.0",
11
+ "version": "0.16.0",
12
12
  "engines": {
13
13
  "node": ">=14.6"
14
14
  },
@@ -20,10 +20,10 @@
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.17.0",
24
- "@xen-orchestra/log": "^0.2.1",
23
+ "@xen-orchestra/fs": "^0.19.1",
24
+ "@xen-orchestra/log": "^0.3.0",
25
25
  "@xen-orchestra/template": "^0.1.0",
26
- "compare-versions": "^3.6.0",
26
+ "compare-versions": "^4.0.1",
27
27
  "d3-time-format": "^3.0.0",
28
28
  "end-of-stream": "^1.4.4",
29
29
  "fs-extra": "^10.0.0",
@@ -32,13 +32,15 @@
32
32
  "lodash": "^4.17.20",
33
33
  "node-zone": "^0.4.0",
34
34
  "parse-pairs": "^1.1.0",
35
- "promise-toolbox": "^0.19.2",
35
+ "promise-toolbox": "^0.20.0",
36
+ "proper-lockfile": "^4.1.2",
36
37
  "pump": "^3.0.0",
37
- "vhd-lib": "^1.1.0",
38
+ "uuid": "^8.3.2",
39
+ "vhd-lib": "^2.0.0",
38
40
  "yazl": "^2.5.1"
39
41
  },
40
42
  "peerDependencies": {
41
- "@xen-orchestra/xapi": "^0.7.0"
43
+ "@xen-orchestra/xapi": "^0.8.0"
42
44
  },
43
45
  "license": "AGPL-3.0-or-later",
44
46
  "author": {
@@ -3,7 +3,7 @@ const map = require('lodash/map.js')
3
3
  const mapValues = require('lodash/mapValues.js')
4
4
  const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
5
5
  const { asyncMap } = require('@xen-orchestra/async-map')
6
- const { chainVhd, checkVhdChain, default: Vhd } = require('vhd-lib')
6
+ const { chainVhd, checkVhdChain, openVhd, VhdAbstract } = require('vhd-lib')
7
7
  const { createLogger } = require('@xen-orchestra/log')
8
8
  const { dirname } = require('path')
9
9
 
@@ -16,6 +16,7 @@ const { MixinBackupWriter } = require('./_MixinBackupWriter.js')
16
16
  const { AbstractDeltaWriter } = require('./_AbstractDeltaWriter.js')
17
17
  const { checkVhd } = require('./_checkVhd.js')
18
18
  const { packUuid } = require('./_packUuid.js')
19
+ const { Disposable } = require('promise-toolbox')
19
20
 
20
21
  const { warn } = createLogger('xo:backups:DeltaBackupWriter')
21
22
 
@@ -37,13 +38,13 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
37
38
  await asyncMap(vhds, async path => {
38
39
  try {
39
40
  await checkVhdChain(handler, path)
40
-
41
- const vhd = new Vhd(handler, path)
42
- await vhd.readHeaderAndFooter()
43
- found = found || vhd.footer.uuid.equals(packUuid(baseUuid))
41
+ await Disposable.use(
42
+ openVhd(handler, path),
43
+ vhd => (found = found || vhd.footer.uuid.equals(packUuid(baseUuid)))
44
+ )
44
45
  } catch (error) {
45
46
  warn('checkBaseVdis', { error })
46
- await ignoreErrors.call(handler.unlink(path))
47
+ await ignoreErrors.call(VhdAbstract.unlink(handler, path))
47
48
  }
48
49
  })
49
50
  } catch (error) {
@@ -113,19 +114,13 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
113
114
  }
114
115
 
115
116
  async _deleteOldEntries() {
116
- return Task.run({ name: 'merge' }, async () => {
117
- const adapter = this._adapter
118
- const oldEntries = this._oldEntries
119
-
120
- let size = 0
121
- // delete sequentially from newest to oldest to avoid unnecessary merges
122
- for (let i = oldEntries.length; i-- > 0; ) {
123
- size += await adapter.deleteDeltaVmBackups([oldEntries[i]])
124
- }
125
- return {
126
- size,
127
- }
128
- })
117
+ const adapter = this._adapter
118
+ const oldEntries = this._oldEntries
119
+
120
+ // delete sequentially from newest to oldest to avoid unnecessary merges
121
+ for (let i = oldEntries.length; i-- > 0; ) {
122
+ await adapter.deleteDeltaVmBackups([oldEntries[i]])
123
+ }
129
124
  }
130
125
 
131
126
  async _transfer({ timestamp, deltaExport, sizeContainers }) {
@@ -150,7 +145,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
150
145
  // don't do delta for it
151
146
  vdi.uuid
152
147
  : vdi.$snapshot_of$uuid
153
- }/${basename}.vhd`
148
+ }/${adapter.getVhdFileName(basename)}`
154
149
  )
155
150
 
156
151
  const metadataFilename = `${backupDir}/${basename}.json`
@@ -194,7 +189,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
194
189
  await checkVhd(handler, parentPath)
195
190
  }
196
191
 
197
- await adapter.outputStream(path, deltaExport.streams[`${id}.vhd`], {
192
+ await adapter.writeVhd(path, deltaExport.streams[`${id}.vhd`], {
198
193
  // no checksum for VHDs, because they will be invalidated by
199
194
  // merges and chainings
200
195
  checksum: false,
@@ -206,11 +201,11 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
206
201
  }
207
202
 
208
203
  // set the correct UUID in the VHD
209
- const vhd = new Vhd(handler, path)
210
- await vhd.readHeaderAndFooter()
211
- vhd.footer.uuid = packUuid(vdi.uuid)
212
- await vhd.readBlockAllocationTable() // required by writeFooter()
213
- await vhd.writeFooter()
204
+ await Disposable.use(openVhd(handler, path), async vhd => {
205
+ vhd.footer.uuid = packUuid(vdi.uuid)
206
+ await vhd.readBlockAllocationTable() // required by writeFooter()
207
+ await vhd.writeFooter()
208
+ })
214
209
  })
215
210
  )
216
211
  return {
@@ -1,34 +1,51 @@
1
1
  const { createLogger } = require('@xen-orchestra/log')
2
+ const { join } = require('path')
2
3
 
3
- const { getVmBackupDir } = require('../_getVmBackupDir.js')
4
+ const { BACKUP_DIR, getVmBackupDir } = require('../_getVmBackupDir.js')
5
+ const MergeWorker = require('../merge-worker/index.js')
6
+ const { formatFilenameDate } = require('../_filenameDate.js')
4
7
 
5
8
  const { warn } = createLogger('xo:backups:MixinBackupWriter')
6
9
 
7
10
  exports.MixinBackupWriter = (BaseClass = Object) =>
8
11
  class MixinBackupWriter extends BaseClass {
12
+ #lock
13
+ #vmBackupDir
14
+
9
15
  constructor({ remoteId, ...rest }) {
10
16
  super(rest)
11
17
 
12
18
  this._adapter = rest.backup.remoteAdapters[remoteId]
13
19
  this._remoteId = remoteId
14
- this._lock = undefined
20
+
21
+ this.#vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
15
22
  }
16
23
 
17
24
  _cleanVm(options) {
18
25
  return this._adapter
19
- .cleanVm(getVmBackupDir(this._backup.vm.uuid), { ...options, fixMetadata: true, onLog: warn, lock: false })
26
+ .cleanVm(this.#vmBackupDir, { ...options, fixMetadata: true, onLog: warn, lock: false })
20
27
  .catch(warn)
21
28
  }
22
29
 
23
30
  async beforeBackup() {
24
31
  const { handler } = this._adapter
25
- const vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
32
+ const vmBackupDir = this.#vmBackupDir
26
33
  await handler.mktree(vmBackupDir)
27
- this._lock = await handler.lock(vmBackupDir)
34
+ this.#lock = await handler.lock(vmBackupDir)
28
35
  }
29
36
 
30
37
  async afterBackup() {
31
- await this._cleanVm({ remove: true, merge: true })
32
- await this._lock.dispose()
38
+ const { disableMergeWorker } = this._backup.config
39
+
40
+ const { merge } = await this._cleanVm({ remove: true, merge: disableMergeWorker })
41
+ await this.#lock.dispose()
42
+
43
+ // merge worker only compatible with local remotes
44
+ const { handler } = this._adapter
45
+ if (merge && !disableMergeWorker && typeof handler._getRealPath === 'function') {
46
+ await handler.outputFile(join(MergeWorker.CLEAN_VM_QUEUE, formatFilenameDate(new Date())), this._backup.vm.uuid)
47
+ const remotePath = handler._getRealPath()
48
+ await MergeWorker.run(remotePath)
49
+ }
33
50
  }
34
51
  }
@@ -1,5 +1,6 @@
1
- const Vhd = require('vhd-lib').default
1
+ const openVhd = require('vhd-lib').openVhd
2
+ const Disposable = require('promise-toolbox/Disposable')
2
3
 
3
4
  exports.checkVhd = async function checkVhd(handler, path) {
4
- await new Vhd(handler, path).readHeaderAndFooter()
5
+ await Disposable.use(openVhd(handler, path), () => {})
5
6
  }