@xen-orchestra/backups 0.18.0 → 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 +19 -8
- package/_VmBackup.js +3 -2
- package/_cleanVm.js +96 -18
- package/_isValidXva.js +19 -6
- package/package.json +3 -3
- package/writers/DeltaBackupWriter.js +8 -1
- package/writers/_MixinBackupWriter.js +5 -4
package/RemoteAdapter.js
CHANGED
|
@@ -68,7 +68,7 @@ const debounceResourceFactory = factory =>
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
class RemoteAdapter {
|
|
71
|
-
constructor(handler, { debounceResource = res => res, dirMode, vhdDirectoryCompression
|
|
71
|
+
constructor(handler, { debounceResource = res => res, dirMode, vhdDirectoryCompression } = {}) {
|
|
72
72
|
this._debounceResource = debounceResource
|
|
73
73
|
this._dirMode = dirMode
|
|
74
74
|
this._handler = handler
|
|
@@ -203,8 +203,8 @@ class RemoteAdapter {
|
|
|
203
203
|
|
|
204
204
|
const isVhdDirectory = vhd instanceof VhdDirectory
|
|
205
205
|
return isVhdDirectory
|
|
206
|
-
? this.#useVhdDirectory && this.#getCompressionType() === vhd.compressionType
|
|
207
|
-
: !this.#useVhdDirectory
|
|
206
|
+
? this.#useVhdDirectory() && this.#getCompressionType() === vhd.compressionType
|
|
207
|
+
: !this.#useVhdDirectory()
|
|
208
208
|
})
|
|
209
209
|
}
|
|
210
210
|
|
|
@@ -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 }) =>
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
//
|
|
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 =
|
|
103
|
+
const INTERRUPTED_VHDS_REG = /^\.(.+)\.merge.json$/
|
|
104
104
|
const listVhds = async (handler, vmDir) => {
|
|
105
|
-
const vhds =
|
|
106
|
-
const
|
|
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.
|
|
127
|
+
vhds.add(`${vdiDir}/${file}`)
|
|
128
128
|
} else {
|
|
129
|
-
|
|
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
|
|
207
|
+
const { vhds, interruptedVhds, aliases } = await listVhds(handler, vmDir)
|
|
156
208
|
|
|
157
209
|
// remove broken VHDs
|
|
158
|
-
await asyncMap(
|
|
210
|
+
await asyncMap(vhds, async path => {
|
|
159
211
|
try {
|
|
160
|
-
await Disposable.use(openVhd(handler, path, { checkSecondFooter: !
|
|
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
|
-
//
|
|
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
|
-
|
|
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) {
|
|
@@ -408,6 +481,11 @@ exports.cleanVm = async function cleanVm(
|
|
|
408
481
|
const linkedVhds = Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
|
|
409
482
|
fileSystemSize = await computeVhdsSize(handler, linkedVhds)
|
|
410
483
|
|
|
484
|
+
// the size is not computed in some cases (e.g. VhdDirectory)
|
|
485
|
+
if (fileSystemSize === undefined) {
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
|
|
411
489
|
// don't warn if the size has changed after a merge
|
|
412
490
|
if (!merged && fileSystemSize !== size) {
|
|
413
491
|
onLog(`incorrect size in metadata: ${size ?? 'none'} instead of ${fileSystemSize}`)
|
package/_isValidXva.js
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
const assert = require('assert')
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const COMPRESSED_MAGIC_NUMBERS = [
|
|
4
4
|
// https://tools.ietf.org/html/rfc1952.html#page-5
|
|
5
|
-
|
|
5
|
+
Buffer.from('1F8B', 'hex'),
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
47
|
-
? true //
|
|
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.
|
|
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.
|
|
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": "^
|
|
39
|
+
"vhd-lib": "^3.1.0",
|
|
40
40
|
"yazl": "^2.5.1"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
@@ -40,7 +40,14 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
|
|
40
40
|
await asyncMap(vhds, async path => {
|
|
41
41
|
try {
|
|
42
42
|
await checkVhdChain(handler, path)
|
|
43
|
-
found = found ||
|
|
43
|
+
// Warning, this should not be written as found = found || await adapter.isMergeableParent(packedBaseUuid, path)
|
|
44
|
+
//
|
|
45
|
+
// since all the checks of a path are done in parallel, found would be containing
|
|
46
|
+
// only the last answer of isMergeableParent which is probably not the right one
|
|
47
|
+
// this led to the support tickets https://help.vates.fr/#ticket/zoom/4751 , 4729, 4665 and 4300
|
|
48
|
+
|
|
49
|
+
const isMergeable = await adapter.isMergeableParent(packedBaseUuid, path)
|
|
50
|
+
found = found || isMergeable
|
|
44
51
|
} catch (error) {
|
|
45
52
|
warn('checkBaseVdis', { error })
|
|
46
53
|
await ignoreErrors.call(VhdAbstract.unlink(handler, path))
|
|
@@ -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:
|
|
51
|
+
const { merge } = await this._cleanVm({ remove: true, merge: !willMergeInWorker })
|
|
49
52
|
await this.#lock.dispose()
|
|
50
53
|
|
|
51
|
-
|
|
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
|
'-' +
|