@xen-orchestra/backups 0.21.1 → 0.24.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
@@ -6,7 +6,7 @@ const ignoreErrors = require('promise-toolbox/ignoreErrors')
6
6
  const { compileTemplate } = require('@xen-orchestra/template')
7
7
  const { limitConcurrency } = require('limit-concurrency-decorator')
8
8
 
9
- const { extractIdsFromSimplePattern } = require('./_extractIdsFromSimplePattern.js')
9
+ const { extractIdsFromSimplePattern } = require('./extractIdsFromSimplePattern.js')
10
10
  const { PoolMetadataBackup } = require('./_PoolMetadataBackup.js')
11
11
  const { Task } = require('./Task.js')
12
12
  const { VmBackup } = require('./_VmBackup.js')
@@ -24,6 +24,34 @@ const getAdaptersByRemote = adapters => {
24
24
 
25
25
  const runTask = (...args) => Task.run(...args).catch(noop) // errors are handled by logs
26
26
 
27
+ const DEFAULT_SETTINGS = {
28
+ reportWhen: 'failure',
29
+ }
30
+
31
+ const DEFAULT_VM_SETTINGS = {
32
+ bypassVdiChainsCheck: false,
33
+ checkpointSnapshot: false,
34
+ concurrency: 2,
35
+ copyRetention: 0,
36
+ deleteFirst: false,
37
+ exportRetention: 0,
38
+ fullInterval: 0,
39
+ healthCheckSr: undefined,
40
+ healthCheckVmsWithTags: [],
41
+ maxMergedDeltasPerRun: 2,
42
+ offlineBackup: false,
43
+ offlineSnapshot: false,
44
+ snapshotRetention: 0,
45
+ timeout: 0,
46
+ unconditionalSnapshot: false,
47
+ vmTimeout: 0,
48
+ }
49
+
50
+ const DEFAULT_METADATA_SETTINGS = {
51
+ retentionPoolMetadata: 0,
52
+ retentionXoMetadata: 0,
53
+ }
54
+
27
55
  exports.Backup = class Backup {
28
56
  constructor({ config, getAdapter, getConnectedRecord, job, schedule }) {
29
57
  this._config = config
@@ -42,17 +70,22 @@ exports.Backup = class Backup {
42
70
  '{job.name}': job.name,
43
71
  '{vm.name_label}': vm => vm.name_label,
44
72
  })
45
- }
46
73
 
47
- run() {
48
- const type = this._job.type
74
+ const { type } = job
75
+ const baseSettings = { ...DEFAULT_SETTINGS }
49
76
  if (type === 'backup') {
50
- return this._runVmBackup()
77
+ Object.assign(baseSettings, DEFAULT_VM_SETTINGS, config.defaultSettings, config.vm?.defaultSettings)
78
+ this.run = this._runVmBackup
51
79
  } else if (type === 'metadataBackup') {
52
- return this._runMetadataBackup()
80
+ Object.assign(baseSettings, DEFAULT_METADATA_SETTINGS, config.defaultSettings, config.metadata?.defaultSettings)
81
+ this.run = this._runMetadataBackup
53
82
  } else {
54
83
  throw new Error(`No runner for the backup type ${type}`)
55
84
  }
85
+ Object.assign(baseSettings, job.settings[''])
86
+
87
+ this._baseSettings = baseSettings
88
+ this._settings = { ...baseSettings, ...job.settings[schedule.id] }
56
89
  }
57
90
 
58
91
  async _runMetadataBackup() {
@@ -64,13 +97,6 @@ exports.Backup = class Backup {
64
97
  }
65
98
 
66
99
  const config = this._config
67
- const settings = {
68
- ...config.defaultSettings,
69
- ...config.metadata.defaultSettings,
70
- ...job.settings[''],
71
- ...job.settings[schedule.id],
72
- }
73
-
74
100
  const poolIds = extractIdsFromSimplePattern(job.pools)
75
101
  const isEmptyPools = poolIds.length === 0
76
102
  const isXoMetadata = job.xoMetadata !== undefined
@@ -78,6 +104,8 @@ exports.Backup = class Backup {
78
104
  throw new Error('no metadata mode found')
79
105
  }
80
106
 
107
+ const settings = this._settings
108
+
81
109
  const { retentionPoolMetadata, retentionXoMetadata } = settings
82
110
 
83
111
  if (
@@ -189,14 +217,7 @@ exports.Backup = class Backup {
189
217
  const schedule = this._schedule
190
218
 
191
219
  const config = this._config
192
- const { settings } = job
193
- const scheduleSettings = {
194
- ...config.defaultSettings,
195
- ...config.vm.defaultSettings,
196
- ...settings[''],
197
- ...settings[schedule.id],
198
- }
199
-
220
+ const settings = this._settings
200
221
  await Disposable.use(
201
222
  Disposable.all(
202
223
  extractIdsFromSimplePattern(job.srs).map(id =>
@@ -224,14 +245,15 @@ exports.Backup = class Backup {
224
245
  })
225
246
  )
226
247
  ),
227
- async (srs, remoteAdapters) => {
248
+ () => settings.healthCheckSr !== undefined ? this._getRecord('SR', settings.healthCheckSr) : undefined,
249
+ async (srs, remoteAdapters, healthCheckSr) => {
228
250
  // remove adapters that failed (already handled)
229
251
  remoteAdapters = remoteAdapters.filter(_ => _ !== undefined)
230
252
 
231
253
  // remove srs that failed (already handled)
232
254
  srs = srs.filter(_ => _ !== undefined)
233
255
 
234
- if (remoteAdapters.length === 0 && srs.length === 0 && scheduleSettings.snapshotRetention === 0) {
256
+ if (remoteAdapters.length === 0 && srs.length === 0 && settings.snapshotRetention === 0) {
235
257
  return
236
258
  }
237
259
 
@@ -241,23 +263,27 @@ exports.Backup = class Backup {
241
263
 
242
264
  remoteAdapters = getAdaptersByRemote(remoteAdapters)
243
265
 
266
+ const allSettings = this._job.settings
267
+ const baseSettings = this._baseSettings
268
+
244
269
  const handleVm = vmUuid =>
245
270
  runTask({ name: 'backup VM', data: { type: 'VM', id: vmUuid } }, () =>
246
271
  Disposable.use(this._getRecord('VM', vmUuid), vm =>
247
272
  new VmBackup({
273
+ baseSettings,
248
274
  config,
249
275
  getSnapshotNameLabel,
276
+ healthCheckSr,
250
277
  job,
251
- // remotes,
252
278
  remoteAdapters,
253
279
  schedule,
254
- settings: { ...scheduleSettings, ...settings[vmUuid] },
280
+ settings: { ...settings, ...allSettings[vm.uuid] },
255
281
  srs,
256
282
  vm,
257
283
  }).run()
258
284
  )
259
285
  )
260
- const { concurrency } = scheduleSettings
286
+ const { concurrency } = settings
261
287
  await asyncMapSettled(vmIds, concurrency === 0 ? handleVm : limitConcurrency(concurrency)(handleVm))
262
288
  }
263
289
  )
@@ -0,0 +1,64 @@
1
+ 'use strict'
2
+
3
+ const { Task } = require('./Task')
4
+
5
+ exports.HealthCheckVmBackup = class HealthCheckVmBackup {
6
+ #xapi
7
+ #restoredVm
8
+
9
+ constructor({ restoredVm, xapi }) {
10
+ this.#restoredVm = restoredVm
11
+ this.#xapi = xapi
12
+ }
13
+
14
+ async run() {
15
+ return Task.run(
16
+ {
17
+ name: 'vmstart',
18
+ },
19
+ async () => {
20
+ let restoredVm = this.#restoredVm
21
+ const xapi = this.#xapi
22
+ const restoredId = restoredVm.uuid
23
+
24
+ // remove vifs
25
+ await Promise.all(restoredVm.$VIFs.map(vif => xapi.callAsync('VIF.destroy', vif.$ref)))
26
+
27
+ const start = new Date()
28
+ // start Vm
29
+
30
+ await xapi.callAsync(
31
+ 'VM.start',
32
+ restoredVm.$ref,
33
+ false, // Start paused?
34
+ false // Skip pre-boot checks?
35
+ )
36
+ const started = new Date()
37
+ const timeout = 10 * 60 * 1000
38
+ const startDuration = started - start
39
+
40
+ let remainingTimeout = timeout - startDuration
41
+
42
+ if (remainingTimeout < 0) {
43
+ throw new Error(`VM ${restoredId} not started after ${timeout / 1000} second`)
44
+ }
45
+
46
+ // wait for the 'Running' event to be really stored in local xapi object cache
47
+ restoredVm = await xapi.waitObjectState(restoredVm.$ref, vm => vm.power_state === 'Running', {
48
+ timeout: remainingTimeout,
49
+ })
50
+
51
+ const running = new Date()
52
+ remainingTimeout -= running - started
53
+
54
+ if (remainingTimeout < 0) {
55
+ throw new Error(`local xapi did not get Runnig state for VM ${restoredId} after ${timeout / 1000} second`)
56
+ }
57
+ // wait for the guest tool version to be defined
58
+ await xapi.waitObjectState(restoredVm.guest_metrics, gm => gm?.PV_drivers_version?.major !== undefined, {
59
+ timeout: remainingTimeout,
60
+ })
61
+ }
62
+ )
63
+ }
64
+ }
package/RemoteAdapter.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
4
+ const { synchronized } = require('decorator-synchronized')
4
5
  const Disposable = require('promise-toolbox/Disposable')
5
6
  const fromCallback = require('promise-toolbox/fromCallback')
6
7
  const fromEvent = require('promise-toolbox/fromEvent')
@@ -9,7 +10,7 @@ const groupBy = require('lodash/groupBy.js')
9
10
  const pickBy = require('lodash/pickBy.js')
10
11
  const { dirname, join, normalize, resolve } = require('path')
11
12
  const { createLogger } = require('@xen-orchestra/log')
12
- const { Constants, createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, VhdSynthetic } = require('vhd-lib')
13
+ const { createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, VhdSynthetic } = require('vhd-lib')
13
14
  const { deduped } = require('@vates/disposable/deduped.js')
14
15
  const { decorateMethodsWith } = require('@vates/decorate-with')
15
16
  const { compose } = require('@vates/compose')
@@ -17,6 +18,7 @@ const { execFile } = require('child_process')
17
18
  const { readdir, stat } = require('fs-extra')
18
19
  const { v4: uuidv4 } = require('uuid')
19
20
  const { ZipFile } = require('yazl')
21
+ const zlib = require('zlib')
20
22
 
21
23
  const { BACKUP_DIR } = require('./_getVmBackupDir.js')
22
24
  const { cleanVm } = require('./_cleanVm.js')
@@ -78,6 +80,7 @@ class RemoteAdapter {
78
80
  this._dirMode = dirMode
79
81
  this._handler = handler
80
82
  this._vhdDirectoryCompression = vhdDirectoryCompression
83
+ this._readCacheListVmBackups = synchronized.withKey()(this._readCacheListVmBackups)
81
84
  }
82
85
 
83
86
  get handler() {
@@ -261,7 +264,8 @@ class RemoteAdapter {
261
264
  }
262
265
 
263
266
  async deleteVmBackups(files) {
264
- const { delta, full, ...others } = groupBy(await asyncMap(files, file => this.readVmBackupMetadata(file)), 'mode')
267
+ const metadatas = await asyncMap(files, file => this.readVmBackupMetadata(file))
268
+ const { delta, full, ...others } = groupBy(metadatas, 'mode')
265
269
 
266
270
  const unsupportedModes = Object.keys(others)
267
271
  if (unsupportedModes.length !== 0) {
@@ -278,6 +282,9 @@ class RemoteAdapter {
278
282
  // don't merge in main process, unused VHDs will be merged in the next backup run
279
283
  await this.cleanVm(dir, { remove: true, onLog: warn })
280
284
  }
285
+
286
+ const dedupedVmUuid = new Set(metadatas.map(_ => _.vm.uuid))
287
+ await asyncMap(dedupedVmUuid, vmUuid => this.invalidateVmBackupListCache(vmUuid))
281
288
  }
282
289
 
283
290
  #getCompressionType() {
@@ -448,34 +455,94 @@ class RemoteAdapter {
448
455
  return backupsByPool
449
456
  }
450
457
 
451
- async listVmBackups(vmUuid, predicate) {
458
+ async invalidateVmBackupListCache(vmUuid) {
459
+ await this.handler.unlink(`${BACKUP_DIR}/${vmUuid}/cache.json.gz`)
460
+ }
461
+
462
+ async #getCachabledDataListVmBackups(dir) {
452
463
  const handler = this._handler
453
- const backups = []
464
+ const backups = {}
454
465
 
455
466
  try {
456
- const files = await handler.list(`${BACKUP_DIR}/${vmUuid}`, {
467
+ const files = await handler.list(dir, {
457
468
  filter: isMetadataFile,
458
469
  prependDir: true,
459
470
  })
460
471
  await asyncMap(files, async file => {
461
472
  try {
462
473
  const metadata = await this.readVmBackupMetadata(file)
463
- if (predicate === undefined || predicate(metadata)) {
464
- // inject an id usable by importVmBackupNg()
465
- metadata.id = metadata._filename
466
-
467
- backups.push(metadata)
468
- }
474
+ // inject an id usable by importVmBackupNg()
475
+ metadata.id = metadata._filename
476
+ backups[file] = metadata
469
477
  } catch (error) {
470
- warn(`listVmBackups ${file}`, { error })
478
+ warn(`can't read vm backup metadata`, { error, file, dir })
471
479
  }
472
480
  })
481
+ return backups
473
482
  } catch (error) {
474
483
  let code
475
484
  if (error == null || ((code = error.code) !== 'ENOENT' && code !== 'ENOTDIR')) {
476
485
  throw error
477
486
  }
478
487
  }
488
+ }
489
+
490
+ // use _ to mark this method as private by convention
491
+ // since we decorate it with synchronized.withKey in the constructor
492
+ // and # function are not writeable.
493
+ //
494
+ // read the list of backup of a Vm from cache
495
+ // if cache is missing or broken => regenerate it and return
496
+
497
+ async _readCacheListVmBackups(vmUuid) {
498
+ const dir = `${BACKUP_DIR}/${vmUuid}`
499
+ const path = `${dir}/cache.json.gz`
500
+
501
+ try {
502
+ const gzipped = await this.handler.readFile(path)
503
+ const text = await fromCallback(zlib.gunzip, gzipped)
504
+ return JSON.parse(text)
505
+ } catch (error) {
506
+ if (error.code !== 'ENOENT') {
507
+ warn('Cache file was unreadable', { vmUuid, error })
508
+ }
509
+ }
510
+
511
+ // nothing cached, or cache unreadable => regenerate it
512
+ const backups = await this.#getCachabledDataListVmBackups(dir)
513
+ if (backups === undefined) {
514
+ return
515
+ }
516
+
517
+ // detached async action, will not reject
518
+ this.#writeVmBackupsCache(path, backups)
519
+
520
+ return backups
521
+ }
522
+
523
+ async #writeVmBackupsCache(cacheFile, backups) {
524
+ try {
525
+ const text = JSON.stringify(backups)
526
+ const zipped = await fromCallback(zlib.gzip, text)
527
+ await this.handler.writeFile(cacheFile, zipped, { flags: 'w' })
528
+ } catch (error) {
529
+ warn('writeVmBackupsCache', { cacheFile, error })
530
+ }
531
+ }
532
+
533
+ async listVmBackups(vmUuid, predicate) {
534
+ const backups = []
535
+ const cached = await this._readCacheListVmBackups(vmUuid)
536
+
537
+ if (cached === undefined) {
538
+ return []
539
+ }
540
+
541
+ Object.values(cached).forEach(metadata => {
542
+ if (predicate === undefined || predicate(metadata)) {
543
+ backups.push(metadata)
544
+ }
545
+ })
479
546
 
480
547
  return backups.sort(compareTimestamp)
481
548
  }
@@ -531,46 +598,27 @@ class RemoteAdapter {
531
598
  })
532
599
  }
533
600
 
534
- async _createSyntheticStream(handler, paths) {
535
- let disposableVhds = []
536
-
537
- // if it's a path : open all hierarchy of parent
538
- if (typeof paths === 'string') {
539
- let vhd
540
- let vhdPath = paths
541
- do {
542
- const disposable = await openVhd(handler, vhdPath)
543
- vhd = disposable.value
544
- disposableVhds.push(disposable)
545
- vhdPath = resolveRelativeFromFile(vhdPath, vhd.header.parentUnicodeName)
546
- } while (vhd.footer.diskType !== Constants.DISK_TYPES.DYNAMIC)
547
- } else {
548
- // only open the list of path given
549
- disposableVhds = paths.map(path => openVhd(handler, path))
550
- }
551
-
601
+ // open the hierarchy of ancestors until we find a full one
602
+ async _createSyntheticStream(handler, path) {
603
+ const disposableSynthetic = await VhdSynthetic.fromVhdChain(handler, path)
552
604
  // I don't want the vhds to be disposed on return
553
605
  // but only when the stream is done ( or failed )
554
- const disposables = await Disposable.all(disposableVhds)
555
- const vhds = disposables.value
556
606
 
557
607
  let disposed = false
558
608
  const disposeOnce = async () => {
559
609
  if (!disposed) {
560
610
  disposed = true
561
-
562
611
  try {
563
- await disposables.dispose()
612
+ await disposableSynthetic.dispose()
564
613
  } catch (error) {
565
- warn('_createSyntheticStream: failed to dispose VHDs', { error })
614
+ warn('openVhd: failed to dispose VHDs', { error })
566
615
  }
567
616
  }
568
617
  }
569
-
570
- const synthetic = new VhdSynthetic(vhds)
571
- await synthetic.readHeaderAndFooter()
618
+ const synthetic = disposableSynthetic.value
572
619
  await synthetic.readBlockAllocationTable()
573
620
  const stream = await synthetic.stream()
621
+
574
622
  stream.on('end', disposeOnce)
575
623
  stream.on('close', disposeOnce)
576
624
  stream.on('error', disposeOnce)
@@ -603,7 +651,10 @@ class RemoteAdapter {
603
651
  }
604
652
 
605
653
  async readVmBackupMetadata(path) {
606
- return Object.defineProperty(JSON.parse(await this._handler.readFile(path)), '_filename', { value: path })
654
+ // _filename is a private field used to compute the backup id
655
+ //
656
+ // it's enumerable to make it cacheable
657
+ return { ...JSON.parse(await this._handler.readFile(path)), _filename: path }
607
658
  }
608
659
  }
609
660
 
package/_VmBackup.js CHANGED
@@ -45,7 +45,18 @@ const forkDeltaExport = deltaExport =>
45
45
  })
46
46
 
47
47
  class VmBackup {
48
- constructor({ config, getSnapshotNameLabel, job, remoteAdapters, remotes, schedule, settings, srs, vm }) {
48
+ constructor({
49
+ config,
50
+ getSnapshotNameLabel,
51
+ healthCheckSr,
52
+ job,
53
+ remoteAdapters,
54
+ remotes,
55
+ schedule,
56
+ settings,
57
+ srs,
58
+ vm,
59
+ }) {
49
60
  if (vm.other_config['xo:backup:job'] === job.id && 'start' in vm.blocked_operations) {
50
61
  // don't match replicated VMs created by this very job otherwise they
51
62
  // will be replicated again and again
@@ -55,7 +66,6 @@ class VmBackup {
55
66
  this.config = config
56
67
  this.job = job
57
68
  this.remoteAdapters = remoteAdapters
58
- this.remotes = remotes
59
69
  this.scheduleId = schedule.id
60
70
  this.timestamp = undefined
61
71
 
@@ -69,6 +79,7 @@ class VmBackup {
69
79
  this._fullVdisRequired = undefined
70
80
  this._getSnapshotNameLabel = getSnapshotNameLabel
71
81
  this._isDelta = job.mode === 'delta'
82
+ this._healthCheckSr = healthCheckSr
72
83
  this._jobId = job.id
73
84
  this._jobSnapshots = undefined
74
85
  this._xapi = vm.$xapi
@@ -95,7 +106,6 @@ class VmBackup {
95
106
  : [FullBackupWriter, FullReplicationWriter]
96
107
 
97
108
  const allSettings = job.settings
98
-
99
109
  Object.keys(remoteAdapters).forEach(remoteId => {
100
110
  const targetSettings = {
101
111
  ...settings,
@@ -173,7 +183,10 @@ class VmBackup {
173
183
  const settings = this._settings
174
184
 
175
185
  const doSnapshot =
176
- this._isDelta || (!settings.offlineBackup && vm.power_state === 'Running') || settings.snapshotRetention !== 0
186
+ settings.unconditionalSnapshot ||
187
+ this._isDelta ||
188
+ (!settings.offlineBackup && vm.power_state === 'Running') ||
189
+ settings.snapshotRetention !== 0
177
190
  if (doSnapshot) {
178
191
  await Task.run({ name: 'snapshot' }, async () => {
179
192
  if (!settings.bypassVdiChainsCheck) {
@@ -181,7 +194,9 @@ class VmBackup {
181
194
  }
182
195
 
183
196
  const snapshotRef = await vm[settings.checkpointSnapshot ? '$checkpoint' : '$snapshot']({
197
+ ignoreNobakVdis: true,
184
198
  name_label: this._getSnapshotNameLabel(vm),
199
+ unplugVusbs: true,
185
200
  })
186
201
  this.timestamp = Date.now()
187
202
 
@@ -303,22 +318,17 @@ class VmBackup {
303
318
  }
304
319
 
305
320
  async _removeUnusedSnapshots() {
306
- const jobSettings = this.job.settings
321
+ const allSettings = this.job.settings
322
+ const baseSettings = this._baseSettings
307
323
  const baseVmRef = this._baseVm?.$ref
308
- const { config } = this
309
- const baseSettings = {
310
- ...config.defaultSettings,
311
- ...config.metadata.defaultSettings,
312
- ...jobSettings[''],
313
- }
314
324
 
315
325
  const snapshotsPerSchedule = groupBy(this._jobSnapshots, _ => _.other_config['xo:backup:schedule'])
316
326
  const xapi = this._xapi
317
327
  await asyncMap(Object.entries(snapshotsPerSchedule), ([scheduleId, snapshots]) => {
318
328
  const settings = {
319
329
  ...baseSettings,
320
- ...jobSettings[scheduleId],
321
- ...jobSettings[this.vm.uuid],
330
+ ...allSettings[scheduleId],
331
+ ...allSettings[this.vm.uuid],
322
332
  }
323
333
  return asyncMap(getOldEntries(settings.snapshotRetention, snapshots), ({ $ref }) => {
324
334
  if ($ref !== baseVmRef) {
@@ -397,6 +407,24 @@ class VmBackup {
397
407
  this._fullVdisRequired = fullVdisRequired
398
408
  }
399
409
 
410
+ async _healthCheck() {
411
+ const settings = this._settings
412
+
413
+ if (this._healthCheckSr === undefined) {
414
+ return
415
+ }
416
+
417
+ // check if current VM has tags
418
+ const { tags } = this.vm
419
+ const intersect = settings.healthCheckVmsWithTags.some(t => tags.includes(t))
420
+
421
+ if (settings.healthCheckVmsWithTags.length !== 0 && !intersect) {
422
+ return
423
+ }
424
+
425
+ await this._callWriters(writer => writer.healthCheck(this._healthCheckSr), 'writer.healthCheck()')
426
+ }
427
+
400
428
  async run($defer) {
401
429
  const settings = this._settings
402
430
  assert(
@@ -406,7 +434,9 @@ class VmBackup {
406
434
 
407
435
  await this._callWriters(async writer => {
408
436
  await writer.beforeBackup()
409
- $defer(() => writer.afterBackup())
437
+ $defer(async () => {
438
+ await writer.afterBackup()
439
+ })
410
440
  }, 'writer.beforeBackup()')
411
441
 
412
442
  await this._fetchJobSnapshots()
@@ -442,6 +472,7 @@ class VmBackup {
442
472
  await this._fetchJobSnapshots()
443
473
  await this._removeUnusedSnapshots()
444
474
  }
475
+ await this._healthCheck()
445
476
  }
446
477
  }
447
478
  exports.VmBackup = VmBackup
package/_cleanVm.js CHANGED
@@ -31,66 +31,53 @@ const computeVhdsSize = (handler, vhdPaths) =>
31
31
  }
32
32
  )
33
33
 
34
- // chain is an array of VHDs from child to parent
34
+ // chain is [ ancestor, child1, ..., childn]
35
+ // 1. Create a VhdSynthetic from all children
36
+ // 2. Merge the VhdSynthetic into the ancestor
37
+ // 3. Delete all (now) unused VHDs
38
+ // 4. Rename the ancestor with the merged data to the latest child
35
39
  //
36
- // the whole chain will be merged into parent, parent will be renamed to child
37
- // and all the others will deleted
38
- async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
40
+ // VhdSynthetic
41
+ // |
42
+ // /‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
43
+ // [ ancestor, child1, ...,child n-1, childn ]
44
+ // | \___________________/ ^
45
+ // | | |
46
+ // | unused VHDs |
47
+ // | |
48
+ // \___________rename_____________/
49
+
50
+ async function mergeVhdChain(chain, { handler, logInfo, remove, merge }) {
39
51
  assert(chain.length >= 2)
40
-
41
- let child = chain[0]
42
- const parent = chain[chain.length - 1]
43
- const children = chain.slice(0, -1).reverse()
44
-
45
- chain
46
- .slice(1)
47
- .reverse()
48
- .forEach(parent => {
49
- onLog(`the parent ${parent} of the child ${child} is unused`)
50
- })
52
+ const chainCopy = [...chain]
53
+ const parent = chainCopy.pop()
54
+ const children = chainCopy
51
55
 
52
56
  if (merge) {
53
- // `mergeVhd` does not work with a stream, either
54
- // - make it accept a stream
55
- // - or create synthetic VHD which is not a stream
56
- if (children.length !== 1) {
57
- // TODO: implement merging multiple children
58
- children.length = 1
59
- child = children[0]
60
- }
61
-
62
- onLog(`merging ${child} into ${parent}`)
57
+ logInfo(`merging children into parent`, { childrenCount: children.length, parent })
63
58
 
64
59
  let done, total
65
60
  const handle = setInterval(() => {
66
61
  if (done !== undefined) {
67
- onLog(`merging ${child}: ${done}/${total}`)
62
+ logInfo(`merging children in progress`, { children, parent, doneCount: done, totalCount: total})
68
63
  }
69
64
  }, 10e3)
70
65
 
71
- const mergedSize = await mergeVhd(
72
- handler,
73
- parent,
74
- handler,
75
- child,
76
- // children.length === 1
77
- // ? child
78
- // : await createSyntheticStream(handler, children),
79
- {
80
- onProgress({ done: d, total: t }) {
81
- done = d
82
- total = t
83
- },
84
- }
85
- )
66
+ const mergedSize = await mergeVhd(handler, parent, handler, children, {
67
+ onProgress({ done: d, total: t }) {
68
+ done = d
69
+ total = t
70
+ },
71
+ })
86
72
 
87
73
  clearInterval(handle)
74
+ const mergeTargetChild = children.shift()
88
75
  await Promise.all([
89
- VhdAbstract.rename(handler, parent, child),
90
- asyncMap(children.slice(0, -1), child => {
91
- onLog(`the VHD ${child} is unused`)
76
+ VhdAbstract.rename(handler, parent, mergeTargetChild),
77
+ asyncMap(children, child => {
78
+ logInfo(`the VHD child is already merged`, { child })
92
79
  if (remove) {
93
- onLog(`deleting unused VHD ${child}`)
80
+ logInfo(`deleting merged VHD child`, { child })
94
81
  return VhdAbstract.unlink(handler, child)
95
82
  }
96
83
  }),
@@ -138,14 +125,19 @@ const listVhds = async (handler, vmDir) => {
138
125
  return { vhds, interruptedVhds, aliases }
139
126
  }
140
127
 
141
- async function checkAliases(aliasPaths, targetDataRepository, { handler, onLog = noop, remove = false }) {
128
+ async function checkAliases(
129
+ aliasPaths,
130
+ targetDataRepository,
131
+ { handler, logInfo = noop, logWarn = console.warn, remove = false }
132
+ ) {
142
133
  const aliasFound = []
143
134
  for (const path of aliasPaths) {
144
135
  const target = await resolveVhdAlias(handler, path)
145
136
 
146
137
  if (!isVhdFile(target)) {
147
- onLog(`Alias ${path} references a non vhd target: ${target}`)
138
+ logWarn('alias references non VHD target', { path, target })
148
139
  if (remove) {
140
+ logInfo('removing alias and non VHD target', { path, target })
149
141
  await handler.unlink(target)
150
142
  await handler.unlink(path)
151
143
  }
@@ -160,13 +152,13 @@ async function checkAliases(aliasPaths, targetDataRepository, { handler, onLog =
160
152
  // error during dispose should not trigger a deletion
161
153
  }
162
154
  } catch (error) {
163
- onLog(`target ${target} of alias ${path} is missing or broken`, { error })
155
+ logWarn('missing or broken alias target', { target, path, error })
164
156
  if (remove) {
165
157
  try {
166
158
  await VhdAbstract.unlink(handler, path)
167
- } catch (e) {
168
- if (e.code !== 'ENOENT') {
169
- onLog(`Error while deleting target ${target} of alias ${path}`, { error: e })
159
+ } catch (error) {
160
+ if (error.code !== 'ENOENT') {
161
+ logWarn('error deleting alias target', { target, path, error })
170
162
  }
171
163
  }
172
164
  }
@@ -183,20 +175,22 @@ async function checkAliases(aliasPaths, targetDataRepository, { handler, onLog =
183
175
 
184
176
  entries.forEach(async entry => {
185
177
  if (!aliasFound.includes(entry)) {
186
- onLog(`the Vhd ${entry} is not referenced by a an alias`)
178
+ logWarn('no alias references VHD', { entry })
187
179
  if (remove) {
180
+ logInfo('deleting unaliased VHD')
188
181
  await VhdAbstract.unlink(handler, entry)
189
182
  }
190
183
  }
191
184
  })
192
185
  }
186
+
193
187
  exports.checkAliases = checkAliases
194
188
 
195
189
  const defaultMergeLimiter = limitConcurrency(1)
196
190
 
197
191
  exports.cleanVm = async function cleanVm(
198
192
  vmDir,
199
- { fixMetadata, remove, merge, mergeLimiter = defaultMergeLimiter, onLog = noop }
193
+ { fixMetadata, remove, merge, mergeLimiter = defaultMergeLimiter, logInfo = noop, logWarn = console.warn }
200
194
  ) {
201
195
  const limitedMergeVhdChain = mergeLimiter(mergeVhdChain)
202
196
 
@@ -227,9 +221,9 @@ exports.cleanVm = async function cleanVm(
227
221
  })
228
222
  } catch (error) {
229
223
  vhds.delete(path)
230
- onLog(`error while checking the VHD with path ${path}`, { error })
224
+ logWarn('VHD check error', { path, error })
231
225
  if (error?.code === 'ERR_ASSERTION' && remove) {
232
- onLog(`deleting broken ${path}`)
226
+ logInfo('deleting broken path', { path })
233
227
  return VhdAbstract.unlink(handler, path)
234
228
  }
235
229
  }
@@ -241,12 +235,12 @@ exports.cleanVm = async function cleanVm(
241
235
  const statePath = interruptedVhds.get(interruptedVhd)
242
236
  interruptedVhds.delete(interruptedVhd)
243
237
 
244
- onLog('orphan merge state', {
238
+ logWarn('orphan merge state', {
245
239
  mergeStatePath: statePath,
246
240
  missingVhdPath: interruptedVhd,
247
241
  })
248
242
  if (remove) {
249
- onLog(`deleting orphan merge state ${statePath}`)
243
+ logInfo('deleting orphan merge state', { statePath })
250
244
  await handler.unlink(statePath)
251
245
  }
252
246
  }
@@ -255,7 +249,7 @@ exports.cleanVm = async function cleanVm(
255
249
  // check if alias are correct
256
250
  // check if all vhd in data subfolder have a corresponding alias
257
251
  await asyncMap(Object.keys(aliases), async dir => {
258
- await checkAliases(aliases[dir], `${dir}/data`, { handler, onLog, remove })
252
+ await checkAliases(aliases[dir], `${dir}/data`, { handler, logInfo, logWarn, remove })
259
253
  })
260
254
 
261
255
  // remove VHDs with missing ancestors
@@ -277,9 +271,9 @@ exports.cleanVm = async function cleanVm(
277
271
  if (!vhds.has(parent)) {
278
272
  vhds.delete(vhdPath)
279
273
 
280
- onLog(`the parent ${parent} of the VHD ${vhdPath} is missing`)
274
+ logWarn('parent VHD is missing', { parent, vhdPath })
281
275
  if (remove) {
282
- onLog(`deleting orphan VHD ${vhdPath}`)
276
+ logInfo('deleting orphan VHD', { vhdPath })
283
277
  deletions.push(VhdAbstract.unlink(handler, vhdPath))
284
278
  }
285
279
  }
@@ -316,7 +310,7 @@ exports.cleanVm = async function cleanVm(
316
310
  // check is not good enough to delete the file, the best we can do is report
317
311
  // it
318
312
  if (!(await this.isValidXva(path))) {
319
- onLog(`the XVA with path ${path} is potentially broken`)
313
+ logWarn('XVA might be broken', { path })
320
314
  }
321
315
  })
322
316
 
@@ -330,7 +324,7 @@ exports.cleanVm = async function cleanVm(
330
324
  try {
331
325
  metadata = JSON.parse(await handler.readFile(json))
332
326
  } catch (error) {
333
- onLog(`failed to read metadata file ${json}`, { error })
327
+ logWarn('failed to read metadata file', { json, error })
334
328
  jsons.delete(json)
335
329
  return
336
330
  }
@@ -341,9 +335,9 @@ exports.cleanVm = async function cleanVm(
341
335
  if (xvas.has(linkedXva)) {
342
336
  unusedXvas.delete(linkedXva)
343
337
  } else {
344
- onLog(`the XVA linked to the metadata ${json} is missing`)
338
+ logWarn('metadata XVA is missing', { json })
345
339
  if (remove) {
346
- onLog(`deleting incomplete backup ${json}`)
340
+ logInfo('deleting incomplete backup', { json })
347
341
  jsons.delete(json)
348
342
  await handler.unlink(json)
349
343
  }
@@ -364,9 +358,9 @@ exports.cleanVm = async function cleanVm(
364
358
  vhdsToJSons[path] = json
365
359
  })
366
360
  } else {
367
- onLog(`Some VHDs linked to the metadata ${json} are missing`, { missingVhds })
361
+ logWarn('some metadata VHDs are missing', { json, missingVhds })
368
362
  if (remove) {
369
- onLog(`deleting incomplete backup ${json}`)
363
+ logInfo('deleting incomplete backup', { json })
370
364
  jsons.delete(json)
371
365
  await handler.unlink(json)
372
366
  }
@@ -407,9 +401,9 @@ exports.cleanVm = async function cleanVm(
407
401
  }
408
402
  }
409
403
 
410
- onLog(`the VHD ${vhd} is unused`)
404
+ logWarn('unused VHD', { vhd })
411
405
  if (remove) {
412
- onLog(`deleting unused VHD ${vhd}`)
406
+ logInfo('deleting unused VHD', { vhd })
413
407
  unusedVhdsDeletion.push(VhdAbstract.unlink(handler, vhd))
414
408
  }
415
409
  }
@@ -433,7 +427,7 @@ exports.cleanVm = async function cleanVm(
433
427
  const metadataWithMergedVhd = {}
434
428
  const doMerge = async () => {
435
429
  await asyncMap(toMerge, async chain => {
436
- const merged = await limitedMergeVhdChain(chain, { handler, onLog, remove, merge })
430
+ const merged = await limitedMergeVhdChain(chain, { handler, logInfo, logWarn, remove, merge })
437
431
  if (merged !== undefined) {
438
432
  const metadataPath = vhdsToJSons[chain[0]] // all the chain should have the same metada file
439
433
  metadataWithMergedVhd[metadataPath] = true
@@ -445,18 +439,18 @@ exports.cleanVm = async function cleanVm(
445
439
  ...unusedVhdsDeletion,
446
440
  toMerge.length !== 0 && (merge ? Task.run({ name: 'merge' }, doMerge) : doMerge()),
447
441
  asyncMap(unusedXvas, path => {
448
- onLog(`the XVA ${path} is unused`)
442
+ logWarn('unused XVA', { path })
449
443
  if (remove) {
450
- onLog(`deleting unused XVA ${path}`)
444
+ logInfo('deleting unused XVA', { path })
451
445
  return handler.unlink(path)
452
446
  }
453
447
  }),
454
448
  asyncMap(xvaSums, path => {
455
449
  // no need to handle checksums for XVAs deleted by the script, they will be handled by `unlink()`
456
450
  if (!xvas.has(path.slice(0, -'.checksum'.length))) {
457
- onLog(`the XVA checksum ${path} is unused`)
451
+ logInfo('unused XVA checksum', { path })
458
452
  if (remove) {
459
- onLog(`deleting unused XVA checksum ${path}`)
453
+ logInfo('deleting unused XVA checksum', { path })
460
454
  return handler.unlink(path)
461
455
  }
462
456
  }
@@ -490,11 +484,11 @@ exports.cleanVm = async function cleanVm(
490
484
 
491
485
  // don't warn if the size has changed after a merge
492
486
  if (!merged && fileSystemSize !== size) {
493
- onLog(`incorrect size in metadata: ${size ?? 'none'} instead of ${fileSystemSize}`)
487
+ logWarn('incorrect size in metadata', { size: size ?? 'none', fileSystemSize })
494
488
  }
495
489
  }
496
490
  } catch (error) {
497
- onLog(`failed to get size of ${metadataPath}`, { error })
491
+ logWarn('failed to get metadata size', { metadataPath, error })
498
492
  return
499
493
  }
500
494
 
@@ -504,7 +498,7 @@ exports.cleanVm = async function cleanVm(
504
498
  try {
505
499
  await handler.writeFile(metadataPath, JSON.stringify(metadata), { flags: 'w' })
506
500
  } catch (error) {
507
- onLog(`failed to update size in backup metadata ${metadataPath} after merge`, { error })
501
+ logWarn('metadata size update failed', { metadataPath, error })
508
502
  }
509
503
  }
510
504
  })
package/_deltaVm.js CHANGED
@@ -65,17 +65,6 @@ exports.exportDeltaVm = async function exportDeltaVm(
65
65
  return
66
66
  }
67
67
 
68
- // If the VDI name start with `[NOBAK]`, do not export it.
69
- if (vdi.name_label.startsWith('[NOBAK]')) {
70
- // FIXME: find a way to not create the VDI snapshot in the
71
- // first time.
72
- //
73
- // The snapshot must not exist otherwise it could break the
74
- // next export.
75
- ignoreErrors.call(vdi.$destroy())
76
- return
77
- }
78
-
79
68
  vbds[vbd.$ref] = vbd
80
69
 
81
70
  const vdiRef = vdi.$ref
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ // eslint-disable-next-line eslint-comments/disable-enable-pair
3
+ /* eslint-disable n/shebang */
2
4
 
3
5
  'use strict'
4
6
 
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.21.1",
11
+ "version": "0.24.0",
12
12
  "engines": {
13
13
  "node": ">=14.6"
14
14
  },
@@ -27,6 +27,7 @@
27
27
  "@xen-orchestra/template": "^0.1.0",
28
28
  "compare-versions": "^4.0.1",
29
29
  "d3-time-format": "^3.0.0",
30
+ "decorator-synchronized": "^0.6.0",
30
31
  "end-of-stream": "^1.4.4",
31
32
  "fs-extra": "^10.0.0",
32
33
  "golike-defer": "^0.5.1",
@@ -37,7 +38,7 @@
37
38
  "promise-toolbox": "^0.21.0",
38
39
  "proper-lockfile": "^4.1.2",
39
40
  "uuid": "^8.3.2",
40
- "vhd-lib": "^3.1.0",
41
+ "vhd-lib": "^3.2.0",
41
42
  "yazl": "^2.5.1"
42
43
  },
43
44
  "devDependencies": {
@@ -45,7 +46,7 @@
45
46
  "tmp": "^0.2.1"
46
47
  },
47
48
  "peerDependencies": {
48
- "@xen-orchestra/xapi": "^0.10.0"
49
+ "@xen-orchestra/xapi": "^1.0.0"
49
50
  },
50
51
  "license": "AGPL-3.0-or-later",
51
52
  "author": {
@@ -19,6 +19,8 @@ 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')
22
24
 
23
25
  const { warn } = createLogger('xo:backups:DeltaBackupWriter')
24
26
 
@@ -69,6 +71,35 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
69
71
  return this._cleanVm({ merge: true })
70
72
  }
71
73
 
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
+
72
103
  prepare({ isFull }) {
73
104
  // create the task related to this export and ensure all methods are called in this context
74
105
  const task = new Task({
@@ -80,7 +111,9 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
80
111
  },
81
112
  })
82
113
  this.transfer = task.wrapFn(this.transfer)
83
- this.cleanup = task.wrapFn(this.cleanup, true)
114
+ this.healthCheck = task.wrapFn(this.healthCheck)
115
+ this.cleanup = task.wrapFn(this.cleanup)
116
+ this.afterBackup = task.wrapFn(this.afterBackup, true)
84
117
 
85
118
  return task.run(() => this._prepare())
86
119
  }
@@ -156,7 +189,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
156
189
  }/${adapter.getVhdFileName(basename)}`
157
190
  )
158
191
 
159
- const metadataFilename = `${backupDir}/${basename}.json`
192
+ const metadataFilename = (this._metadataFileName = `${backupDir}/${basename}.json`)
160
193
  const metadataContent = {
161
194
  jobId,
162
195
  mode: job.mode,
@@ -9,4 +9,6 @@ exports.AbstractWriter = class AbstractWriter {
9
9
  beforeBackup() {}
10
10
 
11
11
  afterBackup() {}
12
+
13
+ healthCheck(sr) {}
12
14
  }
@@ -6,8 +6,9 @@ const { join } = require('path')
6
6
  const { getVmBackupDir } = require('../_getVmBackupDir.js')
7
7
  const MergeWorker = require('../merge-worker/index.js')
8
8
  const { formatFilenameDate } = require('../_filenameDate.js')
9
+ const { Task } = require('../Task.js')
9
10
 
10
- const { warn } = createLogger('xo:backups:MixinBackupWriter')
11
+ const { info, warn } = createLogger('xo:backups:MixinBackupWriter')
11
12
 
12
13
  exports.MixinBackupWriter = (BaseClass = Object) =>
13
14
  class MixinBackupWriter extends BaseClass {
@@ -25,11 +26,17 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
25
26
 
26
27
  async _cleanVm(options) {
27
28
  try {
28
- return await this._adapter.cleanVm(this.#vmBackupDir, {
29
- ...options,
30
- fixMetadata: true,
31
- onLog: warn,
32
- lock: false,
29
+ return await Task.run({ name: 'clean-vm' }, () => {
30
+ return this._adapter.cleanVm(this.#vmBackupDir, {
31
+ ...options,
32
+ fixMetadata: true,
33
+ logInfo: info,
34
+ logWarn: (message, data) => {
35
+ warn(message, data)
36
+ Task.warning(message, data)
37
+ },
38
+ lock: false,
39
+ })
33
40
  })
34
41
  } catch (error) {
35
42
  warn(error)
@@ -64,5 +71,6 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
64
71
  const remotePath = handler._getRealPath()
65
72
  await MergeWorker.run(remotePath)
66
73
  }
74
+ await this._adapter.invalidateVmBackupListCache(this._backup.vm.uuid)
67
75
  }
68
76
  }