@xen-orchestra/backups 0.14.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.
Files changed (43) hide show
  1. package/Backup.js +263 -0
  2. package/DurablePartition.js +40 -0
  3. package/ImportVmBackup.js +66 -0
  4. package/README.md +28 -0
  5. package/RemoteAdapter.js +552 -0
  6. package/RestoreMetadataBackup.js +24 -0
  7. package/Task.js +151 -0
  8. package/_PoolMetadataBackup.js +75 -0
  9. package/_VmBackup.js +409 -0
  10. package/_XoMetadataBackup.js +62 -0
  11. package/_backupType.js +4 -0
  12. package/_backupWorker.js +155 -0
  13. package/_cancelableMap.js +20 -0
  14. package/_cleanVm.js +378 -0
  15. package/_deltaVm.js +347 -0
  16. package/_extractIdsFromSimplePattern.js +29 -0
  17. package/_filenameDate.js +6 -0
  18. package/_forkStreamUnpipe.js +28 -0
  19. package/_getOldEntries.js +4 -0
  20. package/_getTmpDir.js +20 -0
  21. package/_getVmBackupDir.js +6 -0
  22. package/_isValidXva.js +60 -0
  23. package/_listPartitions.js +52 -0
  24. package/_lvm.js +31 -0
  25. package/_watchStreamSize.js +7 -0
  26. package/formatVmBackups.js +34 -0
  27. package/merge-worker/cli.js +69 -0
  28. package/merge-worker/index.js +25 -0
  29. package/package.json +49 -0
  30. package/parseMetadataBackupId.js +23 -0
  31. package/runBackupWorker.js +38 -0
  32. package/writers/DeltaBackupWriter.js +221 -0
  33. package/writers/DeltaReplicationWriter.js +126 -0
  34. package/writers/FullBackupWriter.js +85 -0
  35. package/writers/FullReplicationWriter.js +88 -0
  36. package/writers/_AbstractDeltaWriter.js +26 -0
  37. package/writers/_AbstractFullWriter.js +12 -0
  38. package/writers/_AbstractWriter.js +10 -0
  39. package/writers/_MixinBackupWriter.js +51 -0
  40. package/writers/_MixinReplicationWriter.js +8 -0
  41. package/writers/_checkVhd.js +5 -0
  42. package/writers/_listReplicatedVms.js +30 -0
  43. package/writers/_packUuid.js +5 -0
@@ -0,0 +1,155 @@
1
+ require('@xen-orchestra/log/configure.js').catchGlobalErrors(
2
+ require('@xen-orchestra/log').createLogger('xo:backups:worker')
3
+ )
4
+
5
+ const Disposable = require('promise-toolbox/Disposable.js')
6
+ const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
7
+ const { compose } = require('@vates/compose')
8
+ const { createDebounceResource } = require('@vates/disposable/debounceResource.js')
9
+ const { deduped } = require('@vates/disposable/deduped.js')
10
+ const { getHandler } = require('@xen-orchestra/fs')
11
+ const { parseDuration } = require('@vates/parse-duration')
12
+ const { Xapi } = require('@xen-orchestra/xapi')
13
+
14
+ const { Backup } = require('./Backup.js')
15
+ const { RemoteAdapter } = require('./RemoteAdapter.js')
16
+ const { Task } = require('./Task.js')
17
+
18
+ class BackupWorker {
19
+ #config
20
+ #job
21
+ #recordToXapi
22
+ #remoteOptions
23
+ #remotes
24
+ #schedule
25
+ #xapiOptions
26
+ #xapis
27
+
28
+ constructor({ config, job, recordToXapi, remoteOptions, remotes, resourceCacheDelay, schedule, xapiOptions, xapis }) {
29
+ this.#config = config
30
+ this.#job = job
31
+ this.#recordToXapi = recordToXapi
32
+ this.#remoteOptions = remoteOptions
33
+ this.#remotes = remotes
34
+ this.#schedule = schedule
35
+ this.#xapiOptions = xapiOptions
36
+ this.#xapis = xapis
37
+
38
+ const debounceResource = createDebounceResource()
39
+ debounceResource.defaultDelay = parseDuration(resourceCacheDelay)
40
+ this.debounceResource = debounceResource
41
+ }
42
+
43
+ run() {
44
+ return new Backup({
45
+ config: this.#config,
46
+ getAdapter: remoteId => this.getAdapter(this.#remotes[remoteId]),
47
+ getConnectedRecord: Disposable.factory(async function* getConnectedRecord(type, uuid) {
48
+ const xapiId = this.#recordToXapi[uuid]
49
+ if (xapiId === undefined) {
50
+ throw new Error('no XAPI associated to ' + uuid)
51
+ }
52
+
53
+ const xapi = yield this.getXapi(this.#xapis[xapiId])
54
+ return xapi.getRecordByUuid(type, uuid)
55
+ }).bind(this),
56
+ job: this.#job,
57
+ schedule: this.#schedule,
58
+ }).run()
59
+ }
60
+
61
+ getAdapter = Disposable.factory(this.getAdapter)
62
+ getAdapter = deduped(this.getAdapter, remote => [remote.url])
63
+ getAdapter = compose(this.getAdapter, function (resource) {
64
+ return this.debounceResource(resource)
65
+ })
66
+ async *getAdapter(remote) {
67
+ const handler = getHandler(remote, this.#remoteOptions)
68
+ await handler.sync()
69
+ try {
70
+ yield new RemoteAdapter(handler, {
71
+ debounceResource: this.debounceResource,
72
+ dirMode: this.#config.dirMode,
73
+ })
74
+ } finally {
75
+ await handler.forget()
76
+ }
77
+ }
78
+
79
+ getXapi = Disposable.factory(this.getXapi)
80
+ getXapi = deduped(this.getXapi, ({ url }) => [url])
81
+ getXapi = compose(this.getXapi, function (resource) {
82
+ return this.debounceResource(resource)
83
+ })
84
+ async *getXapi({ credentials: { username: user, password }, ...opts }) {
85
+ const xapi = new Xapi({
86
+ ...this.#xapiOptions,
87
+ ...opts,
88
+ auth: {
89
+ user,
90
+ password,
91
+ },
92
+ })
93
+
94
+ await xapi.connect()
95
+ try {
96
+ await xapi.objectsFetched
97
+
98
+ yield xapi
99
+ } finally {
100
+ await xapi.disconnect()
101
+ }
102
+ }
103
+ }
104
+
105
+ // Received message:
106
+ //
107
+ // Message {
108
+ // action: 'run'
109
+ // data: object
110
+ // runWithLogs: boolean
111
+ // }
112
+ //
113
+ // Sent message:
114
+ //
115
+ // Message {
116
+ // type: 'log' | 'result'
117
+ // data?: object
118
+ // status?: 'success' | 'failure'
119
+ // result?: any
120
+ // }
121
+ process.on('message', async message => {
122
+ if (message.action === 'run') {
123
+ const backupWorker = new BackupWorker(message.data)
124
+ try {
125
+ const result = message.runWithLogs
126
+ ? await Task.run(
127
+ {
128
+ name: 'backup run',
129
+ onLog: data =>
130
+ process.send({
131
+ data,
132
+ type: 'log',
133
+ }),
134
+ },
135
+ () => backupWorker.run()
136
+ )
137
+ : await backupWorker.run()
138
+
139
+ process.send({
140
+ type: 'result',
141
+ result,
142
+ status: 'success',
143
+ })
144
+ } catch (error) {
145
+ process.send({
146
+ type: 'result',
147
+ result: error,
148
+ status: 'failure',
149
+ })
150
+ } finally {
151
+ await ignoreErrors.call(backupWorker.debounceResource.flushAll())
152
+ process.disconnect()
153
+ }
154
+ }
155
+ })
@@ -0,0 +1,20 @@
1
+ const cancelable = require('promise-toolbox/cancelable.js')
2
+ const CancelToken = require('promise-toolbox/CancelToken.js')
3
+
4
+ // Similar to `Promise.all` + `map` but pass a cancel token to the callback
5
+ //
6
+ // If any of the executions fails, the cancel token will be triggered and the
7
+ // first reason will be rejected.
8
+ exports.cancelableMap = cancelable(async function cancelableMap($cancelToken, iterable, callback) {
9
+ const { cancel, token } = CancelToken.source([$cancelToken])
10
+ try {
11
+ return await Promise.all(
12
+ Array.from(iterable, function (item) {
13
+ return callback.call(this, token, item)
14
+ })
15
+ )
16
+ } catch (error) {
17
+ await cancel()
18
+ throw error
19
+ }
20
+ })
package/_cleanVm.js ADDED
@@ -0,0 +1,378 @@
1
+ const assert = require('assert')
2
+ const sum = require('lodash/sum')
3
+ const { asyncMap } = require('@xen-orchestra/async-map')
4
+ const { default: Vhd, mergeVhd } = require('vhd-lib')
5
+ const { dirname, resolve } = require('path')
6
+ const { DISK_TYPE_DIFFERENCING } = require('vhd-lib/dist/_constants.js')
7
+ const { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } = require('./_backupType.js')
8
+ const { limitConcurrency } = require('limit-concurrency-decorator')
9
+
10
+ const { Task } = require('./Task.js')
11
+
12
+ // chain is an array of VHDs from child to parent
13
+ //
14
+ // the whole chain will be merged into parent, parent will be renamed to child
15
+ // and all the others will deleted
16
+ async function mergeVhdChain(chain, { handler, onLog, remove, merge }) {
17
+ assert(chain.length >= 2)
18
+
19
+ let child = chain[0]
20
+ const parent = chain[chain.length - 1]
21
+ const children = chain.slice(0, -1).reverse()
22
+
23
+ chain
24
+ .slice(1)
25
+ .reverse()
26
+ .forEach(parent => {
27
+ onLog(`the parent ${parent} of the child ${child} is unused`)
28
+ })
29
+
30
+ if (merge) {
31
+ // `mergeVhd` does not work with a stream, either
32
+ // - make it accept a stream
33
+ // - or create synthetic VHD which is not a stream
34
+ if (children.length !== 1) {
35
+ // TODO: implement merging multiple children
36
+ children.length = 1
37
+ child = children[0]
38
+ }
39
+
40
+ onLog(`merging ${child} into ${parent}`)
41
+
42
+ let done, total
43
+ const handle = setInterval(() => {
44
+ if (done !== undefined) {
45
+ onLog(`merging ${child}: ${done}/${total}`)
46
+ }
47
+ }, 10e3)
48
+
49
+ const mergedSize = await mergeVhd(
50
+ handler,
51
+ parent,
52
+ handler,
53
+ child,
54
+ // children.length === 1
55
+ // ? child
56
+ // : await createSyntheticStream(handler, children),
57
+ {
58
+ onProgress({ done: d, total: t }) {
59
+ done = d
60
+ total = t
61
+ },
62
+ }
63
+ )
64
+
65
+ clearInterval(handle)
66
+
67
+ await Promise.all([
68
+ handler.rename(parent, child),
69
+ asyncMap(children.slice(0, -1), child => {
70
+ onLog(`the VHD ${child} is unused`)
71
+ if (remove) {
72
+ onLog(`deleting unused VHD ${child}`)
73
+ return handler.unlink(child)
74
+ }
75
+ }),
76
+ ])
77
+
78
+ return mergedSize
79
+ }
80
+ }
81
+
82
+ const noop = Function.prototype
83
+
84
+ const INTERRUPTED_VHDS_REG = /^(?:(.+)\/)?\.(.+)\.merge.json$/
85
+ const listVhds = async (handler, vmDir) => {
86
+ const vhds = []
87
+ const interruptedVhds = new Set()
88
+
89
+ await asyncMap(
90
+ await handler.list(`${vmDir}/vdis`, {
91
+ ignoreMissing: true,
92
+ prependDir: true,
93
+ }),
94
+ async jobDir =>
95
+ asyncMap(
96
+ await handler.list(jobDir, {
97
+ prependDir: true,
98
+ }),
99
+ async vdiDir => {
100
+ const list = await handler.list(vdiDir, {
101
+ filter: file => isVhdFile(file) || INTERRUPTED_VHDS_REG.test(file),
102
+ prependDir: true,
103
+ })
104
+
105
+ list.forEach(file => {
106
+ const res = INTERRUPTED_VHDS_REG.exec(file)
107
+ if (res === null) {
108
+ vhds.push(file)
109
+ } else {
110
+ const [, dir, file] = res
111
+ interruptedVhds.add(`${dir}/${file}`)
112
+ }
113
+ })
114
+ }
115
+ )
116
+ )
117
+
118
+ return { vhds, interruptedVhds }
119
+ }
120
+
121
+ const defaultMergeLimiter = limitConcurrency(1)
122
+
123
+ exports.cleanVm = async function cleanVm(
124
+ vmDir,
125
+ { fixMetadata, remove, merge, mergeLimiter = defaultMergeLimiter, onLog = noop }
126
+ ) {
127
+ const handler = this._handler
128
+
129
+ const vhds = new Set()
130
+ const vhdParents = { __proto__: null }
131
+ const vhdChildren = { __proto__: null }
132
+
133
+ const vhdsList = await listVhds(handler, vmDir)
134
+
135
+ // remove broken VHDs
136
+ await asyncMap(vhdsList.vhds, async path => {
137
+ try {
138
+ const vhd = new Vhd(handler, path)
139
+ await vhd.readHeaderAndFooter(!vhdsList.interruptedVhds.has(path))
140
+ vhds.add(path)
141
+ if (vhd.footer.diskType === DISK_TYPE_DIFFERENCING) {
142
+ const parent = resolve('/', dirname(path), vhd.header.parentUnicodeName)
143
+ vhdParents[path] = parent
144
+ if (parent in vhdChildren) {
145
+ const error = new Error('this script does not support multiple VHD children')
146
+ error.parent = parent
147
+ error.child1 = vhdChildren[parent]
148
+ error.child2 = path
149
+ throw error // should we throw?
150
+ }
151
+ vhdChildren[parent] = path
152
+ }
153
+ } catch (error) {
154
+ onLog(`error while checking the VHD with path ${path}`, { error })
155
+ if (error?.code === 'ERR_ASSERTION' && remove) {
156
+ onLog(`deleting broken ${path}`)
157
+ await handler.unlink(path)
158
+ }
159
+ }
160
+ })
161
+
162
+ // remove VHDs with missing ancestors
163
+ {
164
+ const deletions = []
165
+
166
+ // return true if the VHD has been deleted or is missing
167
+ const deleteIfOrphan = vhd => {
168
+ const parent = vhdParents[vhd]
169
+ if (parent === undefined) {
170
+ return
171
+ }
172
+
173
+ // no longer needs to be checked
174
+ delete vhdParents[vhd]
175
+
176
+ deleteIfOrphan(parent)
177
+
178
+ if (!vhds.has(parent)) {
179
+ vhds.delete(vhd)
180
+
181
+ onLog(`the parent ${parent} of the VHD ${vhd} is missing`)
182
+ if (remove) {
183
+ onLog(`deleting orphan VHD ${vhd}`)
184
+ deletions.push(handler.unlink(vhd))
185
+ }
186
+ }
187
+ }
188
+
189
+ // > A property that is deleted before it has been visited will not be
190
+ // > visited later.
191
+ // >
192
+ // > -- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...in#Deleted_added_or_modified_properties
193
+ for (const child in vhdParents) {
194
+ deleteIfOrphan(child)
195
+ }
196
+
197
+ await Promise.all(deletions)
198
+ }
199
+
200
+ const jsons = []
201
+ const xvas = new Set()
202
+ const xvaSums = []
203
+ const entries = await handler.list(vmDir, {
204
+ prependDir: true,
205
+ })
206
+ entries.forEach(path => {
207
+ if (isMetadataFile(path)) {
208
+ jsons.push(path)
209
+ } else if (isXvaFile(path)) {
210
+ xvas.add(path)
211
+ } else if (isXvaSumFile(path)) {
212
+ xvaSums.push(path)
213
+ }
214
+ })
215
+
216
+ await asyncMap(xvas, async path => {
217
+ // check is not good enough to delete the file, the best we can do is report
218
+ // it
219
+ if (!(await this.isValidXva(path))) {
220
+ onLog(`the XVA with path ${path} is potentially broken`)
221
+ }
222
+ })
223
+
224
+ const unusedVhds = new Set(vhds)
225
+ const unusedXvas = new Set(xvas)
226
+
227
+ // compile the list of unused XVAs and VHDs, and remove backup metadata which
228
+ // reference a missing XVA/VHD
229
+ await asyncMap(jsons, async json => {
230
+ const metadata = JSON.parse(await handler.readFile(json))
231
+ const { mode } = metadata
232
+ let size
233
+ if (mode === 'full') {
234
+ const linkedXva = resolve('/', vmDir, metadata.xva)
235
+
236
+ if (xvas.has(linkedXva)) {
237
+ unusedXvas.delete(linkedXva)
238
+
239
+ size = await handler.getSize(linkedXva).catch(error => {
240
+ onLog(`failed to get size of ${json}`, { error })
241
+ })
242
+ } else {
243
+ onLog(`the XVA linked to the metadata ${json} is missing`)
244
+ if (remove) {
245
+ onLog(`deleting incomplete backup ${json}`)
246
+ await handler.unlink(json)
247
+ }
248
+ }
249
+ } else if (mode === 'delta') {
250
+ const linkedVhds = (() => {
251
+ const { vhds } = metadata
252
+ return Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
253
+ })()
254
+
255
+ // FIXME: find better approach by keeping as much of the backup as
256
+ // possible (existing disks) even if one disk is missing
257
+ if (linkedVhds.every(_ => vhds.has(_))) {
258
+ linkedVhds.forEach(_ => unusedVhds.delete(_))
259
+
260
+ size = await asyncMap(linkedVhds, vhd => handler.getSize(vhd)).then(sum, error => {
261
+ onLog(`failed to get size of ${json}`, { error })
262
+ })
263
+ } else {
264
+ onLog(`Some VHDs linked to the metadata ${json} are missing`)
265
+ if (remove) {
266
+ onLog(`deleting incomplete backup ${json}`)
267
+ await handler.unlink(json)
268
+ }
269
+ }
270
+ }
271
+
272
+ const metadataSize = metadata.size
273
+ if (size !== undefined && metadataSize !== size) {
274
+ onLog(`incorrect size in metadata: ${metadataSize ?? 'none'} instead of ${size}`)
275
+
276
+ // don't update if the the stored size is greater than found files,
277
+ // it can indicates a problem
278
+ if (fixMetadata && (metadataSize === undefined || metadataSize < size)) {
279
+ try {
280
+ metadata.size = size
281
+ await handler.writeFile(json, JSON.stringify(metadata), { flags: 'w' })
282
+ } catch (error) {
283
+ onLog(`failed to update size in backup metadata ${json}`, { error })
284
+ }
285
+ }
286
+ }
287
+ })
288
+
289
+ // TODO: parallelize by vm/job/vdi
290
+ const unusedVhdsDeletion = []
291
+ const toMerge = []
292
+ {
293
+ // VHD chains (as list from child to ancestor) to merge indexed by last
294
+ // ancestor
295
+ const vhdChainsToMerge = { __proto__: null }
296
+
297
+ const toCheck = new Set(unusedVhds)
298
+
299
+ const getUsedChildChainOrDelete = vhd => {
300
+ if (vhd in vhdChainsToMerge) {
301
+ const chain = vhdChainsToMerge[vhd]
302
+ delete vhdChainsToMerge[vhd]
303
+ return chain
304
+ }
305
+
306
+ if (!unusedVhds.has(vhd)) {
307
+ return [vhd]
308
+ }
309
+
310
+ // no longer needs to be checked
311
+ toCheck.delete(vhd)
312
+
313
+ const child = vhdChildren[vhd]
314
+ if (child !== undefined) {
315
+ const chain = getUsedChildChainOrDelete(child)
316
+ if (chain !== undefined) {
317
+ chain.push(vhd)
318
+ return chain
319
+ }
320
+ }
321
+
322
+ onLog(`the VHD ${vhd} is unused`)
323
+ if (remove) {
324
+ onLog(`deleting unused VHD ${vhd}`)
325
+ unusedVhdsDeletion.push(handler.unlink(vhd))
326
+ }
327
+ }
328
+
329
+ toCheck.forEach(vhd => {
330
+ vhdChainsToMerge[vhd] = getUsedChildChainOrDelete(vhd)
331
+ })
332
+
333
+ // merge interrupted VHDs
334
+ vhdsList.interruptedVhds.forEach(parent => {
335
+ vhdChainsToMerge[parent] = [vhdChildren[parent], parent]
336
+ })
337
+
338
+ Object.values(vhdChainsToMerge).forEach(chain => {
339
+ if (chain !== undefined) {
340
+ toMerge.push(chain)
341
+ }
342
+ })
343
+ }
344
+
345
+ const doMerge = () => {
346
+ const promise = asyncMap(toMerge, async chain => {
347
+ mergeVhdChain(chain, { handler, onLog, remove, merge })
348
+ })
349
+ return merge ? promise.then(sizes => ({ size: sum(sizes) })) : promise
350
+ }
351
+
352
+ await Promise.all([
353
+ ...unusedVhdsDeletion,
354
+ toMerge.length !== 0 && (merge ? Task.run({ name: 'merge' }, doMerge) : doMerge()),
355
+ asyncMap(unusedXvas, path => {
356
+ onLog(`the XVA ${path} is unused`)
357
+ if (remove) {
358
+ onLog(`deleting unused XVA ${path}`)
359
+ return handler.unlink(path)
360
+ }
361
+ }),
362
+ asyncMap(xvaSums, path => {
363
+ // no need to handle checksums for XVAs deleted by the script, they will be handled by `unlink()`
364
+ if (!xvas.has(path.slice(0, -'.checksum'.length))) {
365
+ onLog(`the XVA checksum ${path} is unused`)
366
+ if (remove) {
367
+ onLog(`deleting unused XVA checksum ${path}`)
368
+ return handler.unlink(path)
369
+ }
370
+ }
371
+ }),
372
+ ])
373
+
374
+ return {
375
+ // boolean whether some VHDs were merged (or should be merged)
376
+ merge: toMerge.length !== 0,
377
+ }
378
+ }