@xen-orchestra/backups 0.18.3 → 0.19.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
@@ -230,8 +230,8 @@ class RemoteAdapter {
230
230
  async deleteDeltaVmBackups(backups) {
231
231
  const handler = this._handler
232
232
 
233
- // unused VHDs will be detected by `cleanVm`
234
- await asyncMapSettled(backups, ({ _filename }) => VhdAbstract.unlink(handler, _filename))
233
+ // this will delete the json, unused VHDs will be detected by `cleanVm`
234
+ await asyncMapSettled(backups, ({ _filename }) => handler.unlink(_filename))
235
235
  }
236
236
 
237
237
  async deleteMetadataBackup(backupId) {
@@ -277,6 +277,12 @@ class RemoteAdapter {
277
277
  delta !== undefined && this.deleteDeltaVmBackups(delta),
278
278
  full !== undefined && this.deleteFullVmBackups(full),
279
279
  ])
280
+
281
+ const dirs = new Set(files.map(file => dirname(file)))
282
+ for (const dir of dirs) {
283
+ // don't merge in main process, unused VHDs will be merged in the next backup run
284
+ await this.cleanVm(dir, { remove: true, onLog: warn })
285
+ }
280
286
  }
281
287
 
282
288
  #getCompressionType() {
@@ -359,9 +365,14 @@ class RemoteAdapter {
359
365
  const handler = this._handler
360
366
 
361
367
  const backups = { __proto__: null }
362
- await asyncMap(await handler.list(BACKUP_DIR), async vmUuid => {
363
- const vmBackups = await this.listVmBackups(vmUuid)
364
- backups[vmUuid] = vmBackups
368
+ await asyncMap(await handler.list(BACKUP_DIR), async entry => {
369
+ // ignore hidden and lock files
370
+ if (entry[0] !== '.' && !entry.endsWith('.lock')) {
371
+ const vmBackups = await this.listVmBackups(entry)
372
+ if (vmBackups.length !== 0) {
373
+ backups[entry] = vmBackups
374
+ }
375
+ }
365
376
  })
366
377
 
367
378
  return backups
package/_VmBackup.js CHANGED
@@ -36,8 +36,9 @@ 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
39
+ if (vm.other_config['xo:backup:job'] === job.id && 'start' in vm.blocked_operations) {
40
+ // don't match replicated VMs created by this very job otherwise they
41
+ // will be replicated again and again
41
42
  throw new Error('cannot backup a VM created by this very job')
42
43
  }
43
44
 
package/_cleanVm.js CHANGED
@@ -2,6 +2,7 @@ const assert = require('assert')
2
2
  const sum = require('lodash/sum')
3
3
  const { asyncMap } = require('@xen-orchestra/async-map')
4
4
  const { Constants, mergeVhd, openVhd, VhdAbstract, VhdFile } = require('vhd-lib')
5
+ const { isVhdAlias, resolveVhdAlias } = require('vhd-lib/aliases')
5
6
  const { dirname, resolve } = require('path')
6
7
  const { DISK_TYPES } = Constants
7
8
  const { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } = require('./_backupType.js')
@@ -82,7 +83,6 @@ async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
82
83
  )
83
84
 
84
85
  clearInterval(handle)
85
-
86
86
  await Promise.all([
87
87
  VhdAbstract.rename(handler, parent, child),
88
88
  asyncMap(children.slice(0, -1), child => {
@@ -100,10 +100,11 @@ async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
100
100
 
101
101
  const noop = Function.prototype
102
102
 
103
- const INTERRUPTED_VHDS_REG = /^(?:(.+)\/)?\.(.+)\.merge.json$/
103
+ const INTERRUPTED_VHDS_REG = /^\.(.+)\.merge.json$/
104
104
  const listVhds = async (handler, vmDir) => {
105
- const vhds = []
106
- const interruptedVhds = new Set()
105
+ const vhds = new Set()
106
+ const aliases = {}
107
+ const interruptedVhds = new Map()
107
108
 
108
109
  await asyncMap(
109
110
  await handler.list(`${vmDir}/vdis`, {
@@ -118,24 +119,76 @@ const listVhds = async (handler, vmDir) => {
118
119
  async vdiDir => {
119
120
  const list = await handler.list(vdiDir, {
120
121
  filter: file => isVhdFile(file) || INTERRUPTED_VHDS_REG.test(file),
121
- prependDir: true,
122
122
  })
123
-
123
+ aliases[vdiDir] = list.filter(vhd => isVhdAlias(vhd)).map(file => `${vdiDir}/${file}`)
124
124
  list.forEach(file => {
125
125
  const res = INTERRUPTED_VHDS_REG.exec(file)
126
126
  if (res === null) {
127
- vhds.push(file)
127
+ vhds.add(`${vdiDir}/${file}`)
128
128
  } else {
129
- const [, dir, file] = res
130
- interruptedVhds.add(`${dir}/${file}`)
129
+ interruptedVhds.set(`${vdiDir}/${res[1]}`, `${vdiDir}/${file}`)
131
130
  }
132
131
  })
133
132
  }
134
133
  )
135
134
  )
136
135
 
137
- return { vhds, interruptedVhds }
136
+ return { vhds, interruptedVhds, aliases }
137
+ }
138
+
139
+ async function checkAliases(aliasPaths, targetDataRepository, { handler, onLog = noop, remove = false }) {
140
+ const aliasFound = []
141
+ for (const path of aliasPaths) {
142
+ const target = await resolveVhdAlias(handler, path)
143
+
144
+ if (!isVhdFile(target)) {
145
+ onLog(`Alias ${path} references a non vhd target: ${target}`)
146
+ if (remove) {
147
+ await handler.unlink(target)
148
+ await handler.unlink(path)
149
+ }
150
+ continue
151
+ }
152
+
153
+ try {
154
+ const { dispose } = await openVhd(handler, target)
155
+ try {
156
+ await dispose()
157
+ } catch (e) {
158
+ // error during dispose should not trigger a deletion
159
+ }
160
+ } catch (error) {
161
+ onLog(`target ${target} of alias ${path} is missing or broken`, { error })
162
+ if (remove) {
163
+ try {
164
+ await VhdAbstract.unlink(handler, path)
165
+ } catch (e) {
166
+ if (e.code !== 'ENOENT') {
167
+ onLog(`Error while deleting target ${target} of alias ${path}`, { error: e })
168
+ }
169
+ }
170
+ }
171
+ continue
172
+ }
173
+
174
+ aliasFound.push(resolve('/', target))
175
+ }
176
+
177
+ const entries = await handler.list(targetDataRepository, {
178
+ ignoreMissing: true,
179
+ prependDir: true,
180
+ })
181
+
182
+ entries.forEach(async entry => {
183
+ if (!aliasFound.includes(entry)) {
184
+ onLog(`the Vhd ${entry} is not referenced by a an alias`)
185
+ if (remove) {
186
+ await VhdAbstract.unlink(handler, entry)
187
+ }
188
+ }
189
+ })
138
190
  }
191
+ exports.checkAliases = checkAliases
139
192
 
140
193
  const defaultMergeLimiter = limitConcurrency(1)
141
194
 
@@ -147,18 +200,16 @@ exports.cleanVm = async function cleanVm(
147
200
 
148
201
  const handler = this._handler
149
202
 
150
- const vhds = new Set()
151
203
  const vhdsToJSons = new Set()
152
204
  const vhdParents = { __proto__: null }
153
205
  const vhdChildren = { __proto__: null }
154
206
 
155
- const vhdsList = await listVhds(handler, vmDir)
207
+ const { vhds, interruptedVhds, aliases } = await listVhds(handler, vmDir)
156
208
 
157
209
  // remove broken VHDs
158
- await asyncMap(vhdsList.vhds, async path => {
210
+ await asyncMap(vhds, async path => {
159
211
  try {
160
- await Disposable.use(openVhd(handler, path, { checkSecondFooter: !vhdsList.interruptedVhds.has(path) }), vhd => {
161
- vhds.add(path)
212
+ await Disposable.use(openVhd(handler, path, { checkSecondFooter: !interruptedVhds.has(path) }), vhd => {
162
213
  if (vhd.footer.diskType === DISK_TYPES.DIFFERENCING) {
163
214
  const parent = resolve('/', dirname(path), vhd.header.parentUnicodeName)
164
215
  vhdParents[path] = parent
@@ -173,6 +224,7 @@ exports.cleanVm = async function cleanVm(
173
224
  }
174
225
  })
175
226
  } catch (error) {
227
+ vhds.delete(path)
176
228
  onLog(`error while checking the VHD with path ${path}`, { error })
177
229
  if (error?.code === 'ERR_ASSERTION' && remove) {
178
230
  onLog(`deleting broken ${path}`)
@@ -181,7 +233,28 @@ exports.cleanVm = async function cleanVm(
181
233
  }
182
234
  })
183
235
 
184
- // @todo : add check for data folder of alias not referenced in a valid alias
236
+ // remove interrupted merge states for missing VHDs
237
+ for (const interruptedVhd of interruptedVhds.keys()) {
238
+ if (!vhds.has(interruptedVhd)) {
239
+ const statePath = interruptedVhds.get(interruptedVhd)
240
+ interruptedVhds.delete(interruptedVhd)
241
+
242
+ onLog('orphan merge state', {
243
+ mergeStatePath: statePath,
244
+ missingVhdPath: interruptedVhd,
245
+ })
246
+ if (remove) {
247
+ onLog(`deleting orphan merge state ${statePath}`)
248
+ await handler.unlink(statePath)
249
+ }
250
+ }
251
+ }
252
+
253
+ // check if alias are correct
254
+ // check if all vhd in data subfolder have a corresponding alias
255
+ await asyncMap(Object.keys(aliases), async dir => {
256
+ await checkAliases(aliases[dir], `${dir}/data`, { handler, onLog, remove })
257
+ })
185
258
 
186
259
  // remove VHDs with missing ancestors
187
260
  {
@@ -344,9 +417,9 @@ exports.cleanVm = async function cleanVm(
344
417
  })
345
418
 
346
419
  // merge interrupted VHDs
347
- vhdsList.interruptedVhds.forEach(parent => {
420
+ for (const parent of interruptedVhds.keys()) {
348
421
  vhdChainsToMerge[parent] = [vhdChildren[parent], parent]
349
- })
422
+ }
350
423
 
351
424
  Object.values(vhdChainsToMerge).forEach(chain => {
352
425
  if (chain !== undefined) {
package/_isValidXva.js CHANGED
@@ -1,11 +1,24 @@
1
1
  const assert = require('assert')
2
2
 
3
- const isGzipFile = async (handler, fd) => {
3
+ const COMPRESSED_MAGIC_NUMBERS = [
4
4
  // https://tools.ietf.org/html/rfc1952.html#page-5
5
- const magicNumber = Buffer.allocUnsafe(2)
5
+ Buffer.from('1F8B', 'hex'),
6
6
 
7
- assert.strictEqual((await handler.read(fd, magicNumber, 0)).bytesRead, magicNumber.length)
8
- return magicNumber[0] === 31 && magicNumber[1] === 139
7
+ // https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md#zstandard-frames
8
+ Buffer.from('28B52FFD', 'hex'),
9
+ ]
10
+ const MAGIC_NUMBER_MAX_LENGTH = Math.max(...COMPRESSED_MAGIC_NUMBERS.map(_ => _.length))
11
+
12
+ const isCompressedFile = async (handler, fd) => {
13
+ const header = Buffer.allocUnsafe(MAGIC_NUMBER_MAX_LENGTH)
14
+ assert.strictEqual((await handler.read(fd, header, 0)).bytesRead, header.length)
15
+
16
+ for (const magicNumber of COMPRESSED_MAGIC_NUMBERS) {
17
+ if (magicNumber.compare(header, 0, magicNumber.length) === 0) {
18
+ return true
19
+ }
20
+ }
21
+ return false
9
22
  }
10
23
 
11
24
  // TODO: better check?
@@ -43,8 +56,8 @@ async function isValidXva(path) {
43
56
  return false
44
57
  }
45
58
 
46
- return (await isGzipFile(handler, fd))
47
- ? true // gzip files cannot be validated at this time
59
+ return (await isCompressedFile(handler, fd))
60
+ ? true // compressed files cannot be validated at this time
48
61
  : await isValidTar(handler, size, fd)
49
62
  } finally {
50
63
  handler.closeFile(fd).catch(noop)
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.18.3",
11
+ "version": "0.19.0",
12
12
  "engines": {
13
13
  "node": ">=14.6"
14
14
  },
@@ -20,7 +20,7 @@
20
20
  "@vates/disposable": "^0.1.1",
21
21
  "@vates/parse-duration": "^0.1.1",
22
22
  "@xen-orchestra/async-map": "^0.1.2",
23
- "@xen-orchestra/fs": "^0.19.3",
23
+ "@xen-orchestra/fs": "^0.20.0",
24
24
  "@xen-orchestra/log": "^0.3.0",
25
25
  "@xen-orchestra/template": "^0.1.0",
26
26
  "compare-versions": "^4.0.1",
@@ -36,7 +36,7 @@
36
36
  "proper-lockfile": "^4.1.2",
37
37
  "pump": "^3.0.0",
38
38
  "uuid": "^8.3.2",
39
- "vhd-lib": "^3.0.0",
39
+ "vhd-lib": "^3.1.0",
40
40
  "yazl": "^2.5.1"
41
41
  },
42
42
  "peerDependencies": {
@@ -44,13 +44,14 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
44
44
 
45
45
  async afterBackup() {
46
46
  const { disableMergeWorker } = this._backup.config
47
+ // merge worker only compatible with local remotes
48
+ const { handler } = this._adapter
49
+ const willMergeInWorker = !disableMergeWorker && typeof handler._getRealPath === 'function'
47
50
 
48
- const { merge } = await this._cleanVm({ remove: true, merge: disableMergeWorker })
51
+ const { merge } = await this._cleanVm({ remove: true, merge: !willMergeInWorker })
49
52
  await this.#lock.dispose()
50
53
 
51
- // merge worker only compatible with local remotes
52
- const { handler } = this._adapter
53
- if (merge && !disableMergeWorker && typeof handler._getRealPath === 'function') {
54
+ if (merge && willMergeInWorker) {
54
55
  const taskFile =
55
56
  join(MergeWorker.CLEAN_VM_QUEUE, formatFilenameDate(new Date())) +
56
57
  '-' +