@xen-orchestra/backups 0.54.0 → 0.54.2

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.
@@ -79,8 +79,13 @@ export class ImportVmBackup {
79
79
  debug('found disks, wlll search its snapshots', { snapshots: xapiDisk.snapshots })
80
80
  for (const snapshotRef of xapiDisk.snapshots) {
81
81
  const snapshot = await this._xapi.getRecord('VDI', snapshotRef)
82
- debug('handling snapshot', { snapshot })
83
82
 
83
+ debug('handling snapshot', { snapshot })
84
+ if (snapshot.type === 'cbt_metadata') {
85
+ // disk without data can't be used as a base
86
+ debug('cbt metadata snapshot, skip')
87
+ continue
88
+ }
84
89
  // take only the first snapshot
85
90
  if (snapshotCandidate && snapshotCandidate.snapshot_time < snapshot.snapshot_time) {
86
91
  debug('already got a better candidate')
package/RemoteAdapter.mjs CHANGED
@@ -107,10 +107,13 @@ export class RemoteAdapter {
107
107
  async *_getLvmLogicalVolumes(devicePath, pvId, vgName) {
108
108
  yield this._getLvmPhysicalVolume(devicePath, pvId && (await this._findPartition(devicePath, pvId)))
109
109
 
110
+ debug('activate LVM volume group', { vgName })
110
111
  await fromCallback(execFile, 'vgchange', ['-ay', vgName])
111
112
  try {
113
+ debug('get LVM volume group name and path', { vgName })
112
114
  yield lvs(['lv_name', 'lv_path'], vgName)
113
115
  } finally {
116
+ debug('deactivate LVM volume group', { vgName })
114
117
  await fromCallback(execFile, 'vgchange', ['-an', vgName])
115
118
  }
116
119
  }
@@ -121,15 +124,22 @@ export class RemoteAdapter {
121
124
  args.push('-o', partition.start * 512, '--sizelimit', partition.size)
122
125
  }
123
126
  args.push('--show', '-f', devicePath)
127
+
128
+ debug('attach loop device', { devicePath, partition })
124
129
  const path = (await fromCallback(execFile, 'losetup', args)).trim()
125
130
  try {
131
+ debug('list LVM physical volume', { path })
126
132
  await fromCallback(execFile, 'pvscan', ['--cache', path])
133
+
127
134
  yield path
128
135
  } finally {
129
136
  try {
130
137
  const vgNames = await pvs('vg_name', path)
138
+
139
+ debug('deactivate LVM volume groups', { vgNames })
131
140
  await fromCallback(execFile, 'vgchange', ['-an', ...vgNames])
132
141
  } finally {
142
+ debug('detach loop device', { path })
133
143
  await fromCallback(execFile, 'losetup', ['-d', path])
134
144
  }
135
145
  }
@@ -150,6 +160,7 @@ export class RemoteAdapter {
150
160
 
151
161
  const path = yield getTmpDir()
152
162
  const mount = options => {
163
+ debug('mount device', { devicePath, mountPath: path })
153
164
  return fromCallback(execFile, 'mount', [
154
165
  `--options=${options.join(',')}`,
155
166
  `--source=${devicePath}`,
@@ -167,6 +178,7 @@ export class RemoteAdapter {
167
178
  try {
168
179
  yield path
169
180
  } finally {
181
+ debug('umount device', { devicePath, mountPath: path })
170
182
  await fromCallback(execFile, 'umount', ['--lazy', path])
171
183
  }
172
184
  }
@@ -336,6 +348,8 @@ export class RemoteAdapter {
336
348
 
337
349
  const diskPath = handler.getFilePath('/' + diskId)
338
350
  const mountDir = yield getTmpDir()
351
+
352
+ debug('mount VHD (vhdimount)', { diskPath, mountPath: mountDir })
339
353
  await fromCallback(execFile, 'vhdimount', [diskPath, mountDir])
340
354
  try {
341
355
  let max = 0
@@ -357,6 +371,7 @@ export class RemoteAdapter {
357
371
 
358
372
  yield `${mountDir}/${maxEntry}`
359
373
  } finally {
374
+ debug('umount VHD (fusermount)', { diskPath, mountPath: mountDir })
360
375
  await fromCallback(execFile, 'fusermount', ['-uz', mountDir])
361
376
  }
362
377
  }
package/_cleanVm.mjs CHANGED
@@ -121,7 +121,19 @@ export async function checkAliases(
121
121
  ) {
122
122
  const aliasFound = []
123
123
  for (const alias of aliasPaths) {
124
- const target = await resolveVhdAlias(handler, alias)
124
+ let target
125
+ try {
126
+ target = await resolveVhdAlias(handler, alias)
127
+ } catch (err) {
128
+ if (err.code === 'ENOENT') {
129
+ logWarn('missing target of alias', { alias })
130
+ if (remove) {
131
+ logInfo('removing alias and non VHD target', { alias, target })
132
+ await handler.unlink(target)
133
+ await handler.unlink(alias)
134
+ }
135
+ }
136
+ }
125
137
 
126
138
  if (!isVhdFile(target)) {
127
139
  logWarn('alias references non VHD target', { alias, target })
@@ -201,9 +213,9 @@ export async function cleanVm(
201
213
 
202
214
  // remove broken VHDs
203
215
  await asyncMap(vhds, async path => {
204
- if(removeTmp && basename(path)[0] === '.'){
216
+ if (removeTmp && basename(path)[0] === '.') {
205
217
  logInfo('deleting temporary VHD', { path })
206
- return VhdAbstract.unlink(handler, path)
218
+ return VhdAbstract.unlink(handler, path)
207
219
  }
208
220
  try {
209
221
  await Disposable.use(openVhd(handler, path, { checkSecondFooter: !interruptedVhds.has(path) }), vhd => {
package/_getTmpDir.mjs CHANGED
@@ -1,16 +1,23 @@
1
1
  import Disposable from 'promise-toolbox/Disposable'
2
+ import { createLogger } from '@xen-orchestra/log'
2
3
  import { join } from 'node:path'
3
4
  import { mkdir, rmdir } from 'node:fs/promises'
4
5
  import { tmpdir } from 'os'
5
6
 
7
+ const { debug } = createLogger('xo:backups:getTmpDir')
8
+
6
9
  const MAX_ATTEMPTS = 3
7
10
 
8
11
  export async function getTmpDir() {
9
12
  for (let i = 0; true; ++i) {
10
13
  const path = join(tmpdir(), Math.random().toString(36).slice(2))
11
14
  try {
15
+ debug('creating directory', { path })
12
16
  await mkdir(path)
13
- return new Disposable(() => rmdir(path), path)
17
+ return new Disposable(() => {
18
+ debug('removing directory', { path })
19
+ return rmdir(path)
20
+ }, path)
14
21
  } catch (error) {
15
22
  if (i === MAX_ATTEMPTS) {
16
23
  throw error
@@ -52,14 +52,32 @@ export async function exportIncrementalVm(
52
52
  $snapshot_of$uuid: vdi.$snapshot_of?.uuid,
53
53
  $SR$uuid: vdi.$SR.uuid,
54
54
  }
55
-
56
- streams[`${vdiRef}.vhd`] = await vdi.$exportContent({
57
- baseRef: baseVdi?.$ref,
58
- cancelToken,
59
- format: 'vhd',
60
- nbdConcurrency,
61
- preferNbd,
62
- })
55
+ try {
56
+ streams[`${vdiRef}.vhd`] = await vdi.$exportContent({
57
+ baseRef: baseVdi?.$ref,
58
+ cancelToken,
59
+ format: 'vhd',
60
+ nbdConcurrency,
61
+ preferNbd,
62
+ })
63
+ } catch (err) {
64
+ if (err.code === 'VDI_CANT_DO_DELTA') {
65
+ // fall back to a base
66
+ Task.info(`Can't do delta, will try to get a full stream`, { vdi })
67
+ streams[`${vdiRef}.vhd`] = await vdi.$exportContent({
68
+ cancelToken,
69
+ format: 'vhd',
70
+ nbdConcurrency,
71
+ preferNbd,
72
+ })
73
+ // only warn if the fall back succeed
74
+ Task.warning(`Can't do delta with this vdi, transfer will be a full`, {
75
+ vdi,
76
+ })
77
+ } else {
78
+ throw err
79
+ }
80
+ }
63
81
  })
64
82
 
65
83
  const suspendVdi = vm.$suspend_VDI
@@ -1,3 +1,5 @@
1
+ import { createLogger } from '@xen-orchestra/log'
2
+
1
3
  import { asyncEach } from '@vates/async-each'
2
4
  import assert from 'node:assert'
3
5
  import * as UUID from 'uuid'
@@ -11,6 +13,7 @@ import { Disposable } from 'promise-toolbox'
11
13
  import { openVhd } from 'vhd-lib'
12
14
  import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
13
15
 
16
+ const { warn } = createLogger('xo:backups:Incrementalremote')
14
17
  class IncrementalRemoteVmBackupRunner extends AbstractRemote {
15
18
  _getRemoteWriter() {
16
19
  return IncrementalRemoteWriter
@@ -46,11 +49,7 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
46
49
  })
47
50
 
48
51
  const presentBaseVdis = new Map(baseUuidToSrcVdi)
49
- await this._callWriters(
50
- writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis),
51
- 'writer.checkBaseVdis()',
52
- false
53
- )
52
+ await this._callWriters(writer => writer.checkBaseVdis(presentBaseVdis), 'writer.checkBaseVdis()', false)
54
53
  // check if the parent vdi are present in all the remotes
55
54
  baseUuidToSrcVdi.forEach((srcVdiUuid, baseUuid) => {
56
55
  if (!presentBaseVdis.has(baseUuid)) {
@@ -64,17 +63,29 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
64
63
 
65
64
  for (const metadata of transferList) {
66
65
  assert.strictEqual(metadata.mode, 'delta')
67
- await this._selectBaseVm(metadata)
68
- await this._callWriters(writer => writer.prepare({ isBase: metadata.isBase }), 'writer.prepare()')
69
66
  const incrementalExport = await this._sourceRemoteAdapter.readIncrementalVmBackup(metadata, undefined, {
70
67
  useChain: false,
71
68
  })
72
-
69
+ // don't trust metadata too much
70
+ // recompute if it's a base backup
71
+ // recompute if disks are differencing or not
73
72
  const isVhdDifferencing = {}
74
73
 
75
74
  await asyncEach(Object.entries(incrementalExport.streams), async ([key, stream]) => {
76
75
  isVhdDifferencing[key] = await isVhdDifferencingDisk(stream)
77
76
  })
77
+ const hasDifferencingDisk = Object.values(isVhdDifferencing).includes(true)
78
+ if (metadata.isBase === hasDifferencingDisk) {
79
+ warn(`Metadata isBase and real disk value are different`, {
80
+ metadataIsBase: metadata.isBase,
81
+ diskIsBase: !hasDifferencingDisk,
82
+ isVhdDifferencing,
83
+ })
84
+ }
85
+ metadata.isBase = !hasDifferencingDisk
86
+ metadata.isVhdDifferencing = isVhdDifferencing
87
+ await this._selectBaseVm(metadata)
88
+ await this._callWriters(writer => writer.prepare({ isBase: metadata.isBase }), 'writer.prepare()')
78
89
 
79
90
  incrementalExport.streams = mapValues(incrementalExport.streams, this._throttleStream)
80
91
  await this._callWriters(
@@ -203,7 +203,13 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
203
203
  for (const srcVdi of srcVdis) {
204
204
  const snapshots = await xapi.getRecords('VDI', srcVdi.snapshots)
205
205
  for (const snapshot of snapshots) {
206
- if (snapshot.other_config[JOB_ID] === jobId) {
206
+ // only keep the snapshot related to this backup job
207
+ // and only if the job is still using purge snapshot data or if the disk
208
+ // is not a cbt metadata disk ( expect a type: user for normal disks)
209
+ if (
210
+ snapshot.other_config[JOB_ID] === jobId &&
211
+ (this._settings.cbtDestroySnapshotData || snapshot.type !== 'cbt_metadata')
212
+ ) {
207
213
  this._jobSnapshotVdis.push(snapshot)
208
214
  }
209
215
  }
@@ -219,6 +225,14 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
219
225
  await xapi.barrier()
220
226
  // ensure cached object are up to date
221
227
  this._jobSnapshotVdis = this._jobSnapshotVdis.map(vdi => xapi.getObject(vdi.$ref))
228
+
229
+ // get the datetime of the most recent snapshot
230
+ const lastSnapshotDateTime = this._jobSnapshotVdis
231
+ .map(({ other_config }) => other_config[DATETIME])
232
+ .sort()
233
+ .pop()
234
+
235
+ // remove older snapshot schedule per schedule
222
236
  const snapshotsPerSchedule = groupBy(this._jobSnapshotVdis, _ => _.other_config[SCHEDULE_ID])
223
237
  await asyncMap(Object.entries(snapshotsPerSchedule), async ([scheduleId, snapshots]) => {
224
238
  const snapshotPerDatetime = groupBy(snapshots, _ => _.other_config[DATETIME])
@@ -231,10 +245,13 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
231
245
  ...allSettings[scheduleId],
232
246
  ...allSettings[this._vm.uuid],
233
247
  }
234
- // ensure we never delete the last one for delta
235
- const minRetention = this.job.mode === 'delta' ? 1 : 0
236
- const retention = Math.max(settings.snapshotRetention ?? 0, minRetention)
248
+ const retention = settings.snapshotRetention ?? 0
237
249
  await asyncMap(getOldEntries(retention, datetimes), async datetime => {
250
+ // keep the last snapshot across all schedules for delta
251
+ // since we'll need it to compute delta for next backup
252
+ if (this.job.mode === 'delta' && datetime === lastSnapshotDateTime) {
253
+ return
254
+ }
238
255
  const vdis = snapshotPerDatetime[datetime]
239
256
  let vmRef
240
257
  // if there is an attached VM => destroy the VM (Non CBT backups)
@@ -66,7 +66,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
66
66
 
67
67
  async beforeBackup() {
68
68
  await super.beforeBackup()
69
- return this._cleanVm({ merge: true })
69
+ return this._cleanVm({ merge: true, remove: true })
70
70
  }
71
71
 
72
72
  prepare({ isFull }) {
@@ -149,6 +149,9 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
149
149
  assert.notStrictEqual(parentPath, undefined, 'A differential VHD must have a parent')
150
150
  // forbid any kind of loop
151
151
  assert.ok(basename(parentPath) < basename(path), `vhd must be sorted to be chained`)
152
+ // re-chainVhd is mandatory
153
+ // since the parent may be a alias or not
154
+ // and the child may be the other
152
155
  await chainVhd(handler, parentPath, handler, path)
153
156
  }
154
157
 
@@ -15,11 +15,15 @@ import assert from 'node:assert'
15
15
  export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
16
16
  async checkBaseVdis(baseUuidToSrcVdi) {
17
17
  const sr = this._sr
18
+ if (baseUuidToSrcVdi.size === 0) {
19
+ // searching for the vdis is expensive
20
+ // don't do it if there is nothing to find
21
+ return
22
+ }
18
23
 
19
24
  // @todo use an index if possible
20
25
  // @todo : this seems similare to decorateVmMetadata
21
-
22
- const replicatedVdis = sr.$VDIs
26
+ const replicatedVdis = sr.$VDIs
23
27
  .filter(vdi => {
24
28
  // REPLICATED_TO_SR_UUID is not used here since we are already filtering from sr.$VDIs
25
29
  return baseUuidToSrcVdi.has(vdi?.other_config[COPY_OF])
@@ -103,11 +107,10 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
103
107
  .filter(_ => !!_)
104
108
  // @todo use index ?
105
109
 
106
- const replicatedVdis = sr.$VDIs
107
- .filter(vdi => {
108
- // REPLICATED_TO_SR_UUID is not used here since we are already filtering from sr.$VDIs
109
- return sourceVdiUuids.includes(vdi?.other_config[COPY_OF])
110
- })
110
+ const replicatedVdis = sr.$VDIs.filter(vdi => {
111
+ // REPLICATED_TO_SR_UUID is not used here since we are already filtering from sr.$VDIs
112
+ return sourceVdiUuids.includes(vdi?.other_config[COPY_OF])
113
+ })
111
114
 
112
115
  Object.values(backup.vdis).forEach(vdi => {
113
116
  vdi.other_config[COPY_OF] = vdi.uuid
package/package.json CHANGED
@@ -8,13 +8,13 @@
8
8
  "type": "git",
9
9
  "url": "https://github.com/vatesfr/xen-orchestra.git"
10
10
  },
11
- "version": "0.54.0",
11
+ "version": "0.54.2",
12
12
  "engines": {
13
13
  "node": ">=14.18"
14
14
  },
15
15
  "scripts": {
16
16
  "postversion": "npm publish --access public",
17
- "test-integration": "node--test *.integ.mjs"
17
+ "test-integration": "node --test *.integ.mjs"
18
18
  },
19
19
  "dependencies": {
20
20
  "@iarna/toml": "^2.2.5",
@@ -23,13 +23,13 @@
23
23
  "@vates/cached-dns.lookup": "^1.0.0",
24
24
  "@vates/compose": "^2.1.0",
25
25
  "@vates/decorate-with": "^2.1.0",
26
- "@vates/disposable": "^0.1.5",
27
- "@vates/fuse-vhd": "^2.1.1",
28
- "@vates/nbd-client": "^3.1.0",
26
+ "@vates/disposable": "^0.1.6",
27
+ "@vates/fuse-vhd": "^2.1.2",
28
+ "@vates/nbd-client": "^3.1.1",
29
29
  "@vates/parse-duration": "^0.1.1",
30
30
  "@xen-orchestra/async-map": "^0.1.2",
31
- "@xen-orchestra/fs": "^4.1.7",
32
- "@xen-orchestra/log": "^0.6.0",
31
+ "@xen-orchestra/fs": "^4.2.1",
32
+ "@xen-orchestra/log": "^0.7.0",
33
33
  "@xen-orchestra/template": "^0.1.0",
34
34
  "app-conf": "^3.0.0",
35
35
  "compare-versions": "^6.0.0",
@@ -47,19 +47,18 @@
47
47
  "tar": "^6.1.15",
48
48
  "uuid": "^9.0.0",
49
49
  "value-matcher": "^0.2.0",
50
- "vhd-lib": "^4.11.0",
51
- "xen-api": "^4.3.0",
50
+ "vhd-lib": "^4.11.1",
51
+ "xen-api": "^4.5.0",
52
52
  "yazl": "^2.5.1"
53
53
  },
54
54
  "devDependencies": {
55
55
  "fs-extra": "^11.1.0",
56
56
  "rimraf": "^5.0.1",
57
57
  "sinon": "^18.0.0",
58
- "test": "^3.2.1",
59
58
  "tmp": "^0.2.1"
60
59
  },
61
60
  "peerDependencies": {
62
- "@xen-orchestra/xapi": "^7.5.0"
61
+ "@xen-orchestra/xapi": "^7.7.0"
63
62
  },
64
63
  "license": "AGPL-3.0-or-later",
65
64
  "author": {