@xen-orchestra/backups 0.22.0 → 0.23.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.
@@ -0,0 +1,516 @@
1
+ 'use strict'
2
+
3
+ const assert = require('assert')
4
+ const sum = require('lodash/sum')
5
+ const { asyncMap } = require('@xen-orchestra/async-map')
6
+ const { Constants, mergeVhd, openVhd, VhdAbstract, VhdFile } = require('vhd-lib')
7
+ const { isVhdAlias, resolveVhdAlias } = require('vhd-lib/aliases')
8
+ const { dirname, resolve } = require('path')
9
+ const { DISK_TYPES } = Constants
10
+ const { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } = require('./_backupType.js')
11
+ const { limitConcurrency } = require('limit-concurrency-decorator')
12
+
13
+ const { Task } = require('./Task.js')
14
+ const { Disposable } = require('promise-toolbox')
15
+
16
+ // checking the size of a vhd directory is costly
17
+ // 1 Http Query per 1000 blocks
18
+ // we only check size of all the vhd are VhdFiles
19
+ function shouldComputeVhdsSize(vhds) {
20
+ return vhds.every(vhd => vhd instanceof VhdFile)
21
+ }
22
+
23
+ const computeVhdsSize = (handler, vhdPaths) =>
24
+ Disposable.use(
25
+ vhdPaths.map(vhdPath => openVhd(handler, vhdPath)),
26
+ async vhds => {
27
+ if (shouldComputeVhdsSize(vhds)) {
28
+ const sizes = await asyncMap(vhds, vhd => vhd.getSize())
29
+ return sum(sizes)
30
+ }
31
+ }
32
+ )
33
+
34
+ // chain is an array of VHDs from child to parent
35
+ //
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 }) {
39
+ 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
+ })
51
+
52
+ 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}`)
63
+
64
+ let done, total
65
+ const handle = setInterval(() => {
66
+ if (done !== undefined) {
67
+ onLog(`merging ${child}: ${done}/${total}`)
68
+ }
69
+ }, 10e3)
70
+
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
+ )
86
+
87
+ clearInterval(handle)
88
+ await Promise.all([
89
+ VhdAbstract.rename(handler, parent, child),
90
+ asyncMap(children.slice(0, -1), child => {
91
+ onLog(`the VHD ${child} is unused`)
92
+ if (remove) {
93
+ onLog(`deleting unused VHD ${child}`)
94
+ return VhdAbstract.unlink(handler, child)
95
+ }
96
+ }),
97
+ ])
98
+
99
+ return mergedSize
100
+ }
101
+ }
102
+
103
+ const noop = Function.prototype
104
+
105
+ const INTERRUPTED_VHDS_REG = /^\.(.+)\.merge.json$/
106
+ const listVhds = async (handler, vmDir) => {
107
+ const vhds = new Set()
108
+ const aliases = {}
109
+ const interruptedVhds = new Map()
110
+
111
+ await asyncMap(
112
+ await handler.list(`${vmDir}/vdis`, {
113
+ ignoreMissing: true,
114
+ prependDir: true,
115
+ }),
116
+ async jobDir =>
117
+ asyncMap(
118
+ await handler.list(jobDir, {
119
+ prependDir: true,
120
+ }),
121
+ async vdiDir => {
122
+ const list = await handler.list(vdiDir, {
123
+ filter: file => isVhdFile(file) || INTERRUPTED_VHDS_REG.test(file),
124
+ })
125
+ aliases[vdiDir] = list.filter(vhd => isVhdAlias(vhd)).map(file => `${vdiDir}/${file}`)
126
+ list.forEach(file => {
127
+ const res = INTERRUPTED_VHDS_REG.exec(file)
128
+ if (res === null) {
129
+ vhds.add(`${vdiDir}/${file}`)
130
+ } else {
131
+ interruptedVhds.set(`${vdiDir}/${res[1]}`, `${vdiDir}/${file}`)
132
+ }
133
+ })
134
+ }
135
+ )
136
+ )
137
+
138
+ return { vhds, interruptedVhds, aliases }
139
+ }
140
+
141
+ async function checkAliases(aliasPaths, targetDataRepository, { handler, onLog = noop, remove = false }) {
142
+ const aliasFound = []
143
+ for (const path of aliasPaths) {
144
+ const target = await resolveVhdAlias(handler, path)
145
+
146
+ if (!isVhdFile(target)) {
147
+ onLog(`Alias ${path} references a non vhd target: ${target}`)
148
+ if (remove) {
149
+ await handler.unlink(target)
150
+ await handler.unlink(path)
151
+ }
152
+ continue
153
+ }
154
+
155
+ try {
156
+ const { dispose } = await openVhd(handler, target)
157
+ try {
158
+ await dispose()
159
+ } catch (e) {
160
+ // error during dispose should not trigger a deletion
161
+ }
162
+ } catch (error) {
163
+ onLog(`target ${target} of alias ${path} is missing or broken`, { error })
164
+ if (remove) {
165
+ try {
166
+ 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 })
170
+ }
171
+ }
172
+ }
173
+ continue
174
+ }
175
+
176
+ aliasFound.push(resolve('/', target))
177
+ }
178
+
179
+ const entries = await handler.list(targetDataRepository, {
180
+ ignoreMissing: true,
181
+ prependDir: true,
182
+ })
183
+
184
+ entries.forEach(async entry => {
185
+ if (!aliasFound.includes(entry)) {
186
+ onLog(`the Vhd ${entry} is not referenced by a an alias`)
187
+ if (remove) {
188
+ await VhdAbstract.unlink(handler, entry)
189
+ }
190
+ }
191
+ })
192
+ }
193
+ exports.checkAliases = checkAliases
194
+
195
+ const defaultMergeLimiter = limitConcurrency(1)
196
+
197
+ exports.cleanVm = async function cleanVm(
198
+ vmDir,
199
+ { fixMetadata, remove, merge, mergeLimiter = defaultMergeLimiter, onLog = noop }
200
+ ) {
201
+ const limitedMergeVhdChain = mergeLimiter(mergeVhdChain)
202
+
203
+ const handler = this._handler
204
+
205
+ const vhdsToJSons = new Set()
206
+ const vhdParents = { __proto__: null }
207
+ const vhdChildren = { __proto__: null }
208
+
209
+ const { vhds, interruptedVhds, aliases } = await listVhds(handler, vmDir)
210
+
211
+ // remove broken VHDs
212
+ await asyncMap(vhds, async path => {
213
+ try {
214
+ await Disposable.use(openVhd(handler, path, { checkSecondFooter: !interruptedVhds.has(path) }), vhd => {
215
+ if (vhd.footer.diskType === DISK_TYPES.DIFFERENCING) {
216
+ const parent = resolve('/', dirname(path), vhd.header.parentUnicodeName)
217
+ vhdParents[path] = parent
218
+ if (parent in vhdChildren) {
219
+ const error = new Error('this script does not support multiple VHD children')
220
+ error.parent = parent
221
+ error.child1 = vhdChildren[parent]
222
+ error.child2 = path
223
+ throw error // should we throw?
224
+ }
225
+ vhdChildren[parent] = path
226
+ }
227
+ })
228
+ } catch (error) {
229
+ vhds.delete(path)
230
+ onLog(`error while checking the VHD with path ${path}`, { error })
231
+ if (error?.code === 'ERR_ASSERTION' && remove) {
232
+ onLog(`deleting broken ${path}`)
233
+ return VhdAbstract.unlink(handler, path)
234
+ }
235
+ }
236
+ })
237
+
238
+ // remove interrupted merge states for missing VHDs
239
+ for (const interruptedVhd of interruptedVhds.keys()) {
240
+ if (!vhds.has(interruptedVhd)) {
241
+ const statePath = interruptedVhds.get(interruptedVhd)
242
+ interruptedVhds.delete(interruptedVhd)
243
+
244
+ onLog('orphan merge state', {
245
+ mergeStatePath: statePath,
246
+ missingVhdPath: interruptedVhd,
247
+ })
248
+ if (remove) {
249
+ onLog(`deleting orphan merge state ${statePath}`)
250
+ await handler.unlink(statePath)
251
+ }
252
+ }
253
+ }
254
+
255
+ // check if alias are correct
256
+ // check if all vhd in data subfolder have a corresponding alias
257
+ await asyncMap(Object.keys(aliases), async dir => {
258
+ await checkAliases(aliases[dir], `${dir}/data`, { handler, onLog, remove })
259
+ })
260
+
261
+ // remove VHDs with missing ancestors
262
+ {
263
+ const deletions = []
264
+
265
+ // return true if the VHD has been deleted or is missing
266
+ const deleteIfOrphan = vhdPath => {
267
+ const parent = vhdParents[vhdPath]
268
+ if (parent === undefined) {
269
+ return
270
+ }
271
+
272
+ // no longer needs to be checked
273
+ delete vhdParents[vhdPath]
274
+
275
+ deleteIfOrphan(parent)
276
+
277
+ if (!vhds.has(parent)) {
278
+ vhds.delete(vhdPath)
279
+
280
+ onLog(`the parent ${parent} of the VHD ${vhdPath} is missing`)
281
+ if (remove) {
282
+ onLog(`deleting orphan VHD ${vhdPath}`)
283
+ deletions.push(VhdAbstract.unlink(handler, vhdPath))
284
+ }
285
+ }
286
+ }
287
+
288
+ // > A property that is deleted before it has been visited will not be
289
+ // > visited later.
290
+ // >
291
+ // > -- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...in#Deleted_added_or_modified_properties
292
+ for (const child in vhdParents) {
293
+ deleteIfOrphan(child)
294
+ }
295
+
296
+ await Promise.all(deletions)
297
+ }
298
+
299
+ const jsons = new Set()
300
+ const xvas = new Set()
301
+ const xvaSums = []
302
+ const entries = await handler.list(vmDir, {
303
+ prependDir: true,
304
+ })
305
+ entries.forEach(path => {
306
+ if (isMetadataFile(path)) {
307
+ jsons.add(path)
308
+ } else if (isXvaFile(path)) {
309
+ xvas.add(path)
310
+ } else if (isXvaSumFile(path)) {
311
+ xvaSums.push(path)
312
+ }
313
+ })
314
+
315
+ await asyncMap(xvas, async path => {
316
+ // check is not good enough to delete the file, the best we can do is report
317
+ // it
318
+ if (!(await this.isValidXva(path))) {
319
+ onLog(`the XVA with path ${path} is potentially broken`)
320
+ }
321
+ })
322
+
323
+ const unusedVhds = new Set(vhds)
324
+ const unusedXvas = new Set(xvas)
325
+
326
+ // compile the list of unused XVAs and VHDs, and remove backup metadata which
327
+ // reference a missing XVA/VHD
328
+ await asyncMap(jsons, async json => {
329
+ let metadata
330
+ try {
331
+ metadata = JSON.parse(await handler.readFile(json))
332
+ } catch (error) {
333
+ onLog(`failed to read metadata file ${json}`, { error })
334
+ jsons.delete(json)
335
+ return
336
+ }
337
+
338
+ const { mode } = metadata
339
+ if (mode === 'full') {
340
+ const linkedXva = resolve('/', vmDir, metadata.xva)
341
+ if (xvas.has(linkedXva)) {
342
+ unusedXvas.delete(linkedXva)
343
+ } else {
344
+ onLog(`the XVA linked to the metadata ${json} is missing`)
345
+ if (remove) {
346
+ onLog(`deleting incomplete backup ${json}`)
347
+ jsons.delete(json)
348
+ await handler.unlink(json)
349
+ }
350
+ }
351
+ } else if (mode === 'delta') {
352
+ const linkedVhds = (() => {
353
+ const { vhds } = metadata
354
+ return Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
355
+ })()
356
+
357
+ const missingVhds = linkedVhds.filter(_ => !vhds.has(_))
358
+
359
+ // FIXME: find better approach by keeping as much of the backup as
360
+ // possible (existing disks) even if one disk is missing
361
+ if (missingVhds.length === 0) {
362
+ linkedVhds.forEach(_ => unusedVhds.delete(_))
363
+ linkedVhds.forEach(path => {
364
+ vhdsToJSons[path] = json
365
+ })
366
+ } else {
367
+ onLog(`Some VHDs linked to the metadata ${json} are missing`, { missingVhds })
368
+ if (remove) {
369
+ onLog(`deleting incomplete backup ${json}`)
370
+ jsons.delete(json)
371
+ await handler.unlink(json)
372
+ }
373
+ }
374
+ }
375
+ })
376
+
377
+ // TODO: parallelize by vm/job/vdi
378
+ const unusedVhdsDeletion = []
379
+ const toMerge = []
380
+ {
381
+ // VHD chains (as list from child to ancestor) to merge indexed by last
382
+ // ancestor
383
+ const vhdChainsToMerge = { __proto__: null }
384
+
385
+ const toCheck = new Set(unusedVhds)
386
+
387
+ const getUsedChildChainOrDelete = vhd => {
388
+ if (vhd in vhdChainsToMerge) {
389
+ const chain = vhdChainsToMerge[vhd]
390
+ delete vhdChainsToMerge[vhd]
391
+ return chain
392
+ }
393
+
394
+ if (!unusedVhds.has(vhd)) {
395
+ return [vhd]
396
+ }
397
+
398
+ // no longer needs to be checked
399
+ toCheck.delete(vhd)
400
+
401
+ const child = vhdChildren[vhd]
402
+ if (child !== undefined) {
403
+ const chain = getUsedChildChainOrDelete(child)
404
+ if (chain !== undefined) {
405
+ chain.push(vhd)
406
+ return chain
407
+ }
408
+ }
409
+
410
+ onLog(`the VHD ${vhd} is unused`)
411
+ if (remove) {
412
+ onLog(`deleting unused VHD ${vhd}`)
413
+ unusedVhdsDeletion.push(VhdAbstract.unlink(handler, vhd))
414
+ }
415
+ }
416
+
417
+ toCheck.forEach(vhd => {
418
+ vhdChainsToMerge[vhd] = getUsedChildChainOrDelete(vhd)
419
+ })
420
+
421
+ // merge interrupted VHDs
422
+ for (const parent of interruptedVhds.keys()) {
423
+ vhdChainsToMerge[parent] = [vhdChildren[parent], parent]
424
+ }
425
+
426
+ Object.values(vhdChainsToMerge).forEach(chain => {
427
+ if (chain !== undefined) {
428
+ toMerge.push(chain)
429
+ }
430
+ })
431
+ }
432
+
433
+ const metadataWithMergedVhd = {}
434
+ const doMerge = async () => {
435
+ await asyncMap(toMerge, async chain => {
436
+ const merged = await limitedMergeVhdChain(chain, { handler, onLog, remove, merge })
437
+ if (merged !== undefined) {
438
+ const metadataPath = vhdsToJSons[chain[0]] // all the chain should have the same metada file
439
+ metadataWithMergedVhd[metadataPath] = true
440
+ }
441
+ })
442
+ }
443
+
444
+ await Promise.all([
445
+ ...unusedVhdsDeletion,
446
+ toMerge.length !== 0 && (merge ? Task.run({ name: 'merge' }, doMerge) : doMerge()),
447
+ asyncMap(unusedXvas, path => {
448
+ onLog(`the XVA ${path} is unused`)
449
+ if (remove) {
450
+ onLog(`deleting unused XVA ${path}`)
451
+ return handler.unlink(path)
452
+ }
453
+ }),
454
+ asyncMap(xvaSums, path => {
455
+ // no need to handle checksums for XVAs deleted by the script, they will be handled by `unlink()`
456
+ if (!xvas.has(path.slice(0, -'.checksum'.length))) {
457
+ onLog(`the XVA checksum ${path} is unused`)
458
+ if (remove) {
459
+ onLog(`deleting unused XVA checksum ${path}`)
460
+ return handler.unlink(path)
461
+ }
462
+ }
463
+ }),
464
+ ])
465
+
466
+ // update size for delta metadata with merged VHD
467
+ // check for the other that the size is the same as the real file size
468
+
469
+ await asyncMap(jsons, async metadataPath => {
470
+ const metadata = JSON.parse(await handler.readFile(metadataPath))
471
+
472
+ let fileSystemSize
473
+ const merged = metadataWithMergedVhd[metadataPath] !== undefined
474
+
475
+ const { mode, size, vhds, xva } = metadata
476
+
477
+ try {
478
+ if (mode === 'full') {
479
+ // a full backup : check size
480
+ const linkedXva = resolve('/', vmDir, xva)
481
+ fileSystemSize = await handler.getSize(linkedXva)
482
+ } else if (mode === 'delta') {
483
+ const linkedVhds = Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
484
+ fileSystemSize = await computeVhdsSize(handler, linkedVhds)
485
+
486
+ // the size is not computed in some cases (e.g. VhdDirectory)
487
+ if (fileSystemSize === undefined) {
488
+ return
489
+ }
490
+
491
+ // don't warn if the size has changed after a merge
492
+ if (!merged && fileSystemSize !== size) {
493
+ onLog(`incorrect size in metadata: ${size ?? 'none'} instead of ${fileSystemSize}`)
494
+ }
495
+ }
496
+ } catch (error) {
497
+ onLog(`failed to get size of ${metadataPath}`, { error })
498
+ return
499
+ }
500
+
501
+ // systematically update size after a merge
502
+ if ((merged || fixMetadata) && size !== fileSystemSize) {
503
+ metadata.size = fileSystemSize
504
+ try {
505
+ await handler.writeFile(metadataPath, JSON.stringify(metadata), { flags: 'w' })
506
+ } catch (error) {
507
+ onLog(`failed to update size in backup metadata ${metadataPath} after merge`, { error })
508
+ }
509
+ }
510
+ })
511
+
512
+ return {
513
+ // boolean whether some VHDs were merged (or should be merged)
514
+ merge: toMerge.length !== 0,
515
+ }
516
+ }
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.22.0",
11
+ "version": "0.23.0",
12
12
  "engines": {
13
13
  "node": ">=14.6"
14
14
  },
@@ -45,7 +45,7 @@
45
45
  "tmp": "^0.2.1"
46
46
  },
47
47
  "peerDependencies": {
48
- "@xen-orchestra/xapi": "^0.11.0"
48
+ "@xen-orchestra/xapi": "^1.0.0"
49
49
  },
50
50
  "license": "AGPL-3.0-or-later",
51
51
  "author": {