@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.
- package/Backup.js +263 -0
- package/DurablePartition.js +40 -0
- package/ImportVmBackup.js +66 -0
- package/README.md +28 -0
- package/RemoteAdapter.js +552 -0
- package/RestoreMetadataBackup.js +24 -0
- package/Task.js +151 -0
- package/_PoolMetadataBackup.js +75 -0
- package/_VmBackup.js +409 -0
- package/_XoMetadataBackup.js +62 -0
- package/_backupType.js +4 -0
- package/_backupWorker.js +155 -0
- package/_cancelableMap.js +20 -0
- package/_cleanVm.js +378 -0
- package/_deltaVm.js +347 -0
- package/_extractIdsFromSimplePattern.js +29 -0
- package/_filenameDate.js +6 -0
- package/_forkStreamUnpipe.js +28 -0
- package/_getOldEntries.js +4 -0
- package/_getTmpDir.js +20 -0
- package/_getVmBackupDir.js +6 -0
- package/_isValidXva.js +60 -0
- package/_listPartitions.js +52 -0
- package/_lvm.js +31 -0
- package/_watchStreamSize.js +7 -0
- package/formatVmBackups.js +34 -0
- package/merge-worker/cli.js +69 -0
- package/merge-worker/index.js +25 -0
- package/package.json +49 -0
- package/parseMetadataBackupId.js +23 -0
- package/runBackupWorker.js +38 -0
- package/writers/DeltaBackupWriter.js +221 -0
- package/writers/DeltaReplicationWriter.js +126 -0
- package/writers/FullBackupWriter.js +85 -0
- package/writers/FullReplicationWriter.js +88 -0
- package/writers/_AbstractDeltaWriter.js +26 -0
- package/writers/_AbstractFullWriter.js +12 -0
- package/writers/_AbstractWriter.js +10 -0
- package/writers/_MixinBackupWriter.js +51 -0
- package/writers/_MixinReplicationWriter.js +8 -0
- package/writers/_checkVhd.js +5 -0
- package/writers/_listReplicatedVms.js +30 -0
- package/writers/_packUuid.js +5 -0
package/_deltaVm.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
const compareVersions = require('compare-versions')
|
|
2
|
+
const find = require('lodash/find.js')
|
|
3
|
+
const groupBy = require('lodash/groupBy.js')
|
|
4
|
+
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
|
|
5
|
+
const omit = require('lodash/omit.js')
|
|
6
|
+
const { asyncMap } = require('@xen-orchestra/async-map')
|
|
7
|
+
const { CancelToken } = require('promise-toolbox')
|
|
8
|
+
const { createVhdStreamWithLength } = require('vhd-lib')
|
|
9
|
+
const { defer } = require('golike-defer')
|
|
10
|
+
|
|
11
|
+
const { cancelableMap } = require('./_cancelableMap.js')
|
|
12
|
+
|
|
13
|
+
const TAG_BASE_DELTA = 'xo:base_delta'
|
|
14
|
+
exports.TAG_BASE_DELTA = TAG_BASE_DELTA
|
|
15
|
+
|
|
16
|
+
const TAG_COPY_SRC = 'xo:copy_of'
|
|
17
|
+
exports.TAG_COPY_SRC = TAG_COPY_SRC
|
|
18
|
+
|
|
19
|
+
const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value])
|
|
20
|
+
|
|
21
|
+
exports.exportDeltaVm = async function exportDeltaVm(
|
|
22
|
+
vm,
|
|
23
|
+
baseVm,
|
|
24
|
+
{
|
|
25
|
+
cancelToken = CancelToken.none,
|
|
26
|
+
|
|
27
|
+
// Sets of UUIDs of VDIs that must be exported as full.
|
|
28
|
+
fullVdisRequired = new Set(),
|
|
29
|
+
|
|
30
|
+
disableBaseTags = false,
|
|
31
|
+
} = {}
|
|
32
|
+
) {
|
|
33
|
+
// refs of VM's VDIs → base's VDIs.
|
|
34
|
+
const baseVdis = {}
|
|
35
|
+
baseVm &&
|
|
36
|
+
baseVm.$VBDs.forEach(vbd => {
|
|
37
|
+
let vdi, snapshotOf
|
|
38
|
+
if ((vdi = vbd.$VDI) && (snapshotOf = vdi.$snapshot_of) && !fullVdisRequired.has(snapshotOf.uuid)) {
|
|
39
|
+
baseVdis[vdi.snapshot_of] = vdi
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const streams = {}
|
|
44
|
+
const vdis = {}
|
|
45
|
+
const vbds = {}
|
|
46
|
+
await cancelableMap(cancelToken, vm.$VBDs, async (cancelToken, vbd) => {
|
|
47
|
+
let vdi
|
|
48
|
+
if (vbd.type !== 'Disk' || !(vdi = vbd.$VDI)) {
|
|
49
|
+
// Ignore this VBD.
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// If the VDI name start with `[NOBAK]`, do not export it.
|
|
54
|
+
if (vdi.name_label.startsWith('[NOBAK]')) {
|
|
55
|
+
// FIXME: find a way to not create the VDI snapshot in the
|
|
56
|
+
// first time.
|
|
57
|
+
//
|
|
58
|
+
// The snapshot must not exist otherwise it could break the
|
|
59
|
+
// next export.
|
|
60
|
+
ignoreErrors.call(vdi.$destroy())
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
vbds[vbd.$ref] = vbd
|
|
65
|
+
|
|
66
|
+
const vdiRef = vdi.$ref
|
|
67
|
+
if (vdiRef in vdis) {
|
|
68
|
+
// This VDI has already been managed.
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Look for a snapshot of this vdi in the base VM.
|
|
73
|
+
const baseVdi = baseVdis[vdi.snapshot_of]
|
|
74
|
+
|
|
75
|
+
vdis[vdiRef] = {
|
|
76
|
+
...vdi,
|
|
77
|
+
other_config: {
|
|
78
|
+
...vdi.other_config,
|
|
79
|
+
[TAG_BASE_DELTA]: baseVdi && !disableBaseTags ? baseVdi.uuid : undefined,
|
|
80
|
+
},
|
|
81
|
+
$snapshot_of$uuid: vdi.$snapshot_of?.uuid,
|
|
82
|
+
$SR$uuid: vdi.$SR.uuid,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
streams[`${vdiRef}.vhd`] = await vdi.$exportContent({
|
|
86
|
+
baseRef: baseVdi?.$ref,
|
|
87
|
+
cancelToken,
|
|
88
|
+
format: 'vhd',
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const suspendVdi = vm.$suspend_VDI
|
|
93
|
+
if (suspendVdi !== undefined) {
|
|
94
|
+
const vdiRef = suspendVdi.$ref
|
|
95
|
+
vdis[vdiRef] = {
|
|
96
|
+
...suspendVdi,
|
|
97
|
+
$SR$uuid: suspendVdi.$SR.uuid,
|
|
98
|
+
}
|
|
99
|
+
streams[`${vdiRef}.vhd`] = await suspendVdi.$exportContent({
|
|
100
|
+
cancelToken,
|
|
101
|
+
format: 'vhd',
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const vifs = {}
|
|
106
|
+
vm.$VIFs.forEach(vif => {
|
|
107
|
+
const network = vif.$network
|
|
108
|
+
vifs[vif.$ref] = {
|
|
109
|
+
...vif,
|
|
110
|
+
$network$uuid: network.uuid,
|
|
111
|
+
$network$name_label: network.name_label,
|
|
112
|
+
$network$VLAN: network.$PIFs[0]?.VLAN,
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
return Object.defineProperty(
|
|
117
|
+
{
|
|
118
|
+
version: '1.1.0',
|
|
119
|
+
vbds,
|
|
120
|
+
vdis,
|
|
121
|
+
vifs,
|
|
122
|
+
vm: {
|
|
123
|
+
...vm,
|
|
124
|
+
other_config:
|
|
125
|
+
baseVm && !disableBaseTags
|
|
126
|
+
? {
|
|
127
|
+
...vm.other_config,
|
|
128
|
+
[TAG_BASE_DELTA]: baseVm.uuid,
|
|
129
|
+
}
|
|
130
|
+
: omit(vm.other_config, TAG_BASE_DELTA),
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
'streams',
|
|
134
|
+
{
|
|
135
|
+
configurable: true,
|
|
136
|
+
value: streams,
|
|
137
|
+
writable: true,
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
exports.importDeltaVm = defer(async function importDeltaVm(
|
|
143
|
+
$defer,
|
|
144
|
+
deltaVm,
|
|
145
|
+
sr,
|
|
146
|
+
{ cancelToken = CancelToken.none, detectBase = true, mapVdisSrs = {}, newMacAddresses = false } = {}
|
|
147
|
+
) {
|
|
148
|
+
const { version } = deltaVm
|
|
149
|
+
if (compareVersions(version, '1.0.0') < 0) {
|
|
150
|
+
throw new Error(`Unsupported delta backup version: ${version}`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const vmRecord = deltaVm.vm
|
|
154
|
+
const xapi = sr.$xapi
|
|
155
|
+
|
|
156
|
+
let baseVm
|
|
157
|
+
if (detectBase) {
|
|
158
|
+
const remoteBaseVmUuid = vmRecord.other_config[TAG_BASE_DELTA]
|
|
159
|
+
if (remoteBaseVmUuid) {
|
|
160
|
+
baseVm = find(xapi.objects.all, obj => (obj = obj.other_config) && obj[TAG_COPY_SRC] === remoteBaseVmUuid)
|
|
161
|
+
|
|
162
|
+
if (!baseVm) {
|
|
163
|
+
throw new Error(`could not find the base VM (copy of ${remoteBaseVmUuid})`)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const baseVdis = {}
|
|
169
|
+
baseVm &&
|
|
170
|
+
baseVm.$VBDs.forEach(vbd => {
|
|
171
|
+
const vdi = vbd.$VDI
|
|
172
|
+
if (vdi !== undefined) {
|
|
173
|
+
baseVdis[vbd.VDI] = vbd.$VDI
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
const vdiRecords = deltaVm.vdis
|
|
177
|
+
|
|
178
|
+
// 0. Create suspend_VDI
|
|
179
|
+
let suspendVdi
|
|
180
|
+
if (vmRecord.power_state === 'Suspended') {
|
|
181
|
+
const vdi = vdiRecords[vmRecord.suspend_VDI]
|
|
182
|
+
suspendVdi = await xapi.getRecord(
|
|
183
|
+
'VDI',
|
|
184
|
+
await xapi.VDI_create({
|
|
185
|
+
...vdi,
|
|
186
|
+
other_config: {
|
|
187
|
+
...vdi.other_config,
|
|
188
|
+
[TAG_BASE_DELTA]: undefined,
|
|
189
|
+
[TAG_COPY_SRC]: vdi.uuid,
|
|
190
|
+
},
|
|
191
|
+
sr: mapVdisSrs[vdi.uuid] ?? sr.$ref,
|
|
192
|
+
})
|
|
193
|
+
)
|
|
194
|
+
$defer.onFailure(() => suspendVdi.$destroy())
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 1. Create the VM.
|
|
198
|
+
const vmRef = await xapi.VM_create(
|
|
199
|
+
{
|
|
200
|
+
...vmRecord,
|
|
201
|
+
affinity: undefined,
|
|
202
|
+
blocked_operations: {
|
|
203
|
+
...vmRecord.blocked_operations,
|
|
204
|
+
start: 'Importing…',
|
|
205
|
+
start_on: 'Importing…',
|
|
206
|
+
},
|
|
207
|
+
ha_always_run: false,
|
|
208
|
+
is_a_template: false,
|
|
209
|
+
name_label: '[Importing…] ' + vmRecord.name_label,
|
|
210
|
+
other_config: {
|
|
211
|
+
...vmRecord.other_config,
|
|
212
|
+
[TAG_COPY_SRC]: vmRecord.uuid,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
bios_strings: vmRecord.bios_strings,
|
|
217
|
+
generateMacSeed: newMacAddresses,
|
|
218
|
+
suspend_VDI: suspendVdi?.$ref,
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
$defer.onFailure.call(xapi, 'VM_destroy', vmRef)
|
|
222
|
+
|
|
223
|
+
// 2. Delete all VBDs which may have been created by the import.
|
|
224
|
+
await asyncMap(await xapi.getField('VM', vmRef, 'VBDs'), ref => ignoreErrors.call(xapi.call('VBD.destroy', ref)))
|
|
225
|
+
|
|
226
|
+
// 3. Create VDIs & VBDs.
|
|
227
|
+
const vbdRecords = deltaVm.vbds
|
|
228
|
+
const vbds = groupBy(vbdRecords, 'VDI')
|
|
229
|
+
const newVdis = {}
|
|
230
|
+
await asyncMap(Object.keys(vdiRecords), async vdiRef => {
|
|
231
|
+
const vdi = vdiRecords[vdiRef]
|
|
232
|
+
let newVdi
|
|
233
|
+
|
|
234
|
+
const remoteBaseVdiUuid = detectBase && vdi.other_config[TAG_BASE_DELTA]
|
|
235
|
+
if (remoteBaseVdiUuid) {
|
|
236
|
+
const baseVdi = find(baseVdis, vdi => vdi.other_config[TAG_COPY_SRC] === remoteBaseVdiUuid)
|
|
237
|
+
if (!baseVdi) {
|
|
238
|
+
throw new Error(`missing base VDI (copy of ${remoteBaseVdiUuid})`)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
newVdi = await xapi.getRecord('VDI', await baseVdi.$clone())
|
|
242
|
+
$defer.onFailure(() => newVdi.$destroy())
|
|
243
|
+
|
|
244
|
+
await newVdi.update_other_config(TAG_COPY_SRC, vdi.uuid)
|
|
245
|
+
} else if (vdiRef === vmRecord.suspend_VDI) {
|
|
246
|
+
// suspendVDI has already created
|
|
247
|
+
newVdi = suspendVdi
|
|
248
|
+
} else {
|
|
249
|
+
newVdi = await xapi.getRecord(
|
|
250
|
+
'VDI',
|
|
251
|
+
await xapi.VDI_create({
|
|
252
|
+
...vdi,
|
|
253
|
+
other_config: {
|
|
254
|
+
...vdi.other_config,
|
|
255
|
+
[TAG_BASE_DELTA]: undefined,
|
|
256
|
+
[TAG_COPY_SRC]: vdi.uuid,
|
|
257
|
+
},
|
|
258
|
+
SR: mapVdisSrs[vdi.uuid] ?? sr.$ref,
|
|
259
|
+
})
|
|
260
|
+
)
|
|
261
|
+
$defer.onFailure(() => newVdi.$destroy())
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const vdiVbds = vbds[vdiRef]
|
|
265
|
+
if (vdiVbds !== undefined) {
|
|
266
|
+
await asyncMap(Object.values(vdiVbds), vbd =>
|
|
267
|
+
xapi.VBD_create({
|
|
268
|
+
...vbd,
|
|
269
|
+
VDI: newVdi.$ref,
|
|
270
|
+
VM: vmRef,
|
|
271
|
+
})
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
newVdis[vdiRef] = newVdi
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const networksByNameLabelByVlan = {}
|
|
279
|
+
let defaultNetwork
|
|
280
|
+
Object.values(xapi.objects.all).forEach(object => {
|
|
281
|
+
if (object.$type === 'network') {
|
|
282
|
+
const pif = object.$PIFs[0]
|
|
283
|
+
if (pif === undefined) {
|
|
284
|
+
// ignore network
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
const vlan = pif.VLAN
|
|
288
|
+
const networksByNameLabel = networksByNameLabelByVlan[vlan] || (networksByNameLabelByVlan[vlan] = {})
|
|
289
|
+
defaultNetwork = networksByNameLabel[object.name_label] = object
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
const { streams } = deltaVm
|
|
294
|
+
|
|
295
|
+
await Promise.all([
|
|
296
|
+
// Import VDI contents.
|
|
297
|
+
cancelableMap(cancelToken, Object.entries(newVdis), async (cancelToken, [id, vdi]) => {
|
|
298
|
+
for (let stream of ensureArray(streams[`${id}.vhd`])) {
|
|
299
|
+
if (typeof stream === 'function') {
|
|
300
|
+
stream = await stream()
|
|
301
|
+
}
|
|
302
|
+
if (stream.length === undefined) {
|
|
303
|
+
stream = await createVhdStreamWithLength(stream)
|
|
304
|
+
}
|
|
305
|
+
await vdi.$importContent(stream, { cancelToken, format: 'vhd' })
|
|
306
|
+
}
|
|
307
|
+
}),
|
|
308
|
+
|
|
309
|
+
// Create VIFs.
|
|
310
|
+
asyncMap(Object.values(deltaVm.vifs), vif => {
|
|
311
|
+
let network = vif.$network$uuid && xapi.getObjectByUuid(vif.$network$uuid, undefined)
|
|
312
|
+
|
|
313
|
+
if (network === undefined) {
|
|
314
|
+
const { $network$VLAN: vlan = -1 } = vif
|
|
315
|
+
const networksByNameLabel = networksByNameLabelByVlan[vlan]
|
|
316
|
+
if (networksByNameLabel !== undefined) {
|
|
317
|
+
network = networksByNameLabel[vif.$network$name_label]
|
|
318
|
+
if (network === undefined) {
|
|
319
|
+
network = networksByNameLabel[Object.keys(networksByNameLabel)[0]]
|
|
320
|
+
}
|
|
321
|
+
} else {
|
|
322
|
+
network = defaultNetwork
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (network) {
|
|
327
|
+
return xapi.VIF_create(
|
|
328
|
+
{
|
|
329
|
+
...vif,
|
|
330
|
+
network: network.$ref,
|
|
331
|
+
VM: vmRef,
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
MAC: newMacAddresses ? undefined : vif.MAC,
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
}),
|
|
339
|
+
])
|
|
340
|
+
|
|
341
|
+
await Promise.all([
|
|
342
|
+
deltaVm.vm.ha_always_run && xapi.setField('VM', vmRef, 'ha_always_run', true),
|
|
343
|
+
xapi.setField('VM', vmRef, 'name_label', deltaVm.vm.name_label),
|
|
344
|
+
])
|
|
345
|
+
|
|
346
|
+
return vmRef
|
|
347
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
exports.extractIdsFromSimplePattern = function extractIdsFromSimplePattern(pattern) {
|
|
2
|
+
if (pattern === undefined) {
|
|
3
|
+
return []
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
if (pattern !== null && typeof pattern === 'object') {
|
|
7
|
+
let keys = Object.keys(pattern)
|
|
8
|
+
|
|
9
|
+
if (keys.length === 1 && keys[0] === 'id') {
|
|
10
|
+
pattern = pattern.id
|
|
11
|
+
if (typeof pattern === 'string') {
|
|
12
|
+
return [pattern]
|
|
13
|
+
}
|
|
14
|
+
if (pattern !== null && typeof pattern === 'object') {
|
|
15
|
+
keys = Object.keys(pattern)
|
|
16
|
+
if (
|
|
17
|
+
keys.length === 1 &&
|
|
18
|
+
keys[0] === '__or' &&
|
|
19
|
+
Array.isArray((pattern = pattern.__or)) &&
|
|
20
|
+
pattern.every(_ => typeof _ === 'string')
|
|
21
|
+
) {
|
|
22
|
+
return pattern
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
throw new Error('invalid pattern')
|
|
29
|
+
}
|
package/_filenameDate.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const eos = require('end-of-stream')
|
|
2
|
+
const { PassThrough } = require('stream')
|
|
3
|
+
|
|
4
|
+
// create a new readable stream from an existing one which may be piped later
|
|
5
|
+
//
|
|
6
|
+
// in case of error in the new readable stream, it will simply be unpiped
|
|
7
|
+
// from the original one
|
|
8
|
+
exports.forkStreamUnpipe = function forkStreamUnpipe(stream) {
|
|
9
|
+
const { forks = 0 } = stream
|
|
10
|
+
stream.forks = forks + 1
|
|
11
|
+
|
|
12
|
+
const proxy = new PassThrough()
|
|
13
|
+
stream.pipe(proxy)
|
|
14
|
+
eos(stream, error => {
|
|
15
|
+
if (error !== undefined) {
|
|
16
|
+
proxy.destroy(error)
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
eos(proxy, _ => {
|
|
20
|
+
stream.forks--
|
|
21
|
+
stream.unpipe(proxy)
|
|
22
|
+
|
|
23
|
+
if (stream.forks === 0) {
|
|
24
|
+
stream.destroy(new Error('no more consumers for this stream'))
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
return proxy
|
|
28
|
+
}
|
package/_getTmpDir.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const Disposable = require('promise-toolbox/Disposable.js')
|
|
2
|
+
const { join } = require('path')
|
|
3
|
+
const { mkdir, rmdir } = require('fs-extra')
|
|
4
|
+
const { tmpdir } = require('os')
|
|
5
|
+
|
|
6
|
+
const MAX_ATTEMPTS = 3
|
|
7
|
+
|
|
8
|
+
exports.getTmpDir = async function getTmpDir() {
|
|
9
|
+
for (let i = 0; true; ++i) {
|
|
10
|
+
const path = join(tmpdir(), Math.random().toString(36).slice(2))
|
|
11
|
+
try {
|
|
12
|
+
await mkdir(path)
|
|
13
|
+
return new Disposable(() => rmdir(path), path)
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (i === MAX_ATTEMPTS) {
|
|
16
|
+
throw error
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
package/_isValidXva.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const assert = require('assert')
|
|
2
|
+
|
|
3
|
+
const isGzipFile = async (handler, fd) => {
|
|
4
|
+
// https://tools.ietf.org/html/rfc1952.html#page-5
|
|
5
|
+
const magicNumber = Buffer.allocUnsafe(2)
|
|
6
|
+
|
|
7
|
+
assert.strictEqual((await handler.read(fd, magicNumber, 0)).bytesRead, magicNumber.length)
|
|
8
|
+
return magicNumber[0] === 31 && magicNumber[1] === 139
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// TODO: better check?
|
|
12
|
+
//
|
|
13
|
+
// our heuristic is not good enough, there has been some false positives
|
|
14
|
+
// (detected as invalid by us but valid by `tar` and imported with success),
|
|
15
|
+
// either THOUGH THEY MAY HAVE BEEN COMPRESSED FILES:
|
|
16
|
+
// - these files were normal but the check is incorrect
|
|
17
|
+
// - these files were invalid but without data loss
|
|
18
|
+
// - these files were invalid but with silent data loss
|
|
19
|
+
//
|
|
20
|
+
// maybe reading the end of the file looking for a file named
|
|
21
|
+
// /^Ref:\d+/\d+\.checksum$/ and then validating the tar structure from it
|
|
22
|
+
//
|
|
23
|
+
// https://github.com/npm/node-tar/issues/234#issuecomment-538190295
|
|
24
|
+
const isValidTar = async (handler, size, fd) => {
|
|
25
|
+
if (size <= 1024 || size % 512 !== 0) {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const buf = Buffer.allocUnsafe(1024)
|
|
30
|
+
assert.strictEqual((await handler.read(fd, buf, size - buf.length)).bytesRead, buf.length)
|
|
31
|
+
return buf.every(_ => _ === 0)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// TODO: find an heuristic for compressed files
|
|
35
|
+
async function isValidXva(path) {
|
|
36
|
+
const handler = this._handler
|
|
37
|
+
try {
|
|
38
|
+
const fd = await handler.openFile(path, 'r')
|
|
39
|
+
try {
|
|
40
|
+
const size = await handler.getSize(fd)
|
|
41
|
+
if (size < 20) {
|
|
42
|
+
// neither a valid gzip not tar
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (await isGzipFile(handler, fd))
|
|
47
|
+
? true // gzip files cannot be validated at this time
|
|
48
|
+
: await isValidTar(handler, size, fd)
|
|
49
|
+
} finally {
|
|
50
|
+
handler.closeFile(fd).catch(noop)
|
|
51
|
+
}
|
|
52
|
+
} catch (error) {
|
|
53
|
+
// never throw, log and report as valid to avoid side effects
|
|
54
|
+
console.error('isValidXva', path, error)
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
exports.isValidXva = isValidXva
|
|
59
|
+
|
|
60
|
+
const noop = Function.prototype
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const fromCallback = require('promise-toolbox/fromCallback.js')
|
|
2
|
+
const { createLogger } = require('@xen-orchestra/log')
|
|
3
|
+
const { createParser } = require('parse-pairs')
|
|
4
|
+
const { execFile } = require('child_process')
|
|
5
|
+
|
|
6
|
+
const { debug } = createLogger('xo:backups:listPartitions')
|
|
7
|
+
|
|
8
|
+
const IGNORED_PARTITION_TYPES = {
|
|
9
|
+
// https://github.com/jhermsmeier/node-mbr/blob/master/lib/partition.js#L38
|
|
10
|
+
0x05: true,
|
|
11
|
+
0x0f: true,
|
|
12
|
+
0x15: true,
|
|
13
|
+
0x5e: true,
|
|
14
|
+
0x5f: true,
|
|
15
|
+
0x85: true,
|
|
16
|
+
0x91: true,
|
|
17
|
+
0x9b: true,
|
|
18
|
+
0xc5: true,
|
|
19
|
+
0xcf: true,
|
|
20
|
+
0xd5: true,
|
|
21
|
+
|
|
22
|
+
0x82: true, // swap
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const LVM_PARTITION_TYPE = 0x8e
|
|
26
|
+
exports.LVM_PARTITION_TYPE = LVM_PARTITION_TYPE
|
|
27
|
+
|
|
28
|
+
const parsePartxLine = createParser({
|
|
29
|
+
keyTransform: key => (key === 'UUID' ? 'id' : key.toLowerCase()),
|
|
30
|
+
valueTransform: (value, key) => (key === 'start' || key === 'size' || key === 'type' ? +value : value),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// returns an empty array in case of a non-partitioned disk
|
|
34
|
+
exports.listPartitions = async function listPartitions(devicePath) {
|
|
35
|
+
const parts = await fromCallback(execFile, 'partx', [
|
|
36
|
+
'--bytes',
|
|
37
|
+
'--output=NR,START,SIZE,NAME,UUID,TYPE',
|
|
38
|
+
'--pairs',
|
|
39
|
+
devicePath,
|
|
40
|
+
]).catch(error => {
|
|
41
|
+
// partx returns 1 since v2.33 when failing to read partitions.
|
|
42
|
+
//
|
|
43
|
+
// Prior versions are correctly handled by the nominal case.
|
|
44
|
+
debug('listPartitions', { error })
|
|
45
|
+
return ''
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return parts
|
|
49
|
+
.split(/\r?\n/)
|
|
50
|
+
.map(parsePartxLine)
|
|
51
|
+
.filter(({ type }) => type != null && !(type in IGNORED_PARTITION_TYPES))
|
|
52
|
+
}
|
package/_lvm.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const fromCallback = require('promise-toolbox/fromCallback.js')
|
|
2
|
+
const { createParser } = require('parse-pairs')
|
|
3
|
+
const { execFile } = require('child_process')
|
|
4
|
+
|
|
5
|
+
// ===================================================================
|
|
6
|
+
|
|
7
|
+
const parse = createParser({
|
|
8
|
+
keyTransform: key => key.slice(5).toLowerCase(),
|
|
9
|
+
})
|
|
10
|
+
const makeFunction =
|
|
11
|
+
command =>
|
|
12
|
+
async (fields, ...args) => {
|
|
13
|
+
const info = await fromCallback(execFile, command, [
|
|
14
|
+
'--noheading',
|
|
15
|
+
'--nosuffix',
|
|
16
|
+
'--nameprefixes',
|
|
17
|
+
'--unbuffered',
|
|
18
|
+
'--units',
|
|
19
|
+
'b',
|
|
20
|
+
'-o',
|
|
21
|
+
String(fields),
|
|
22
|
+
...args,
|
|
23
|
+
])
|
|
24
|
+
return info
|
|
25
|
+
.trim()
|
|
26
|
+
.split(/\r?\n/)
|
|
27
|
+
.map(Array.isArray(fields) ? parse : line => parse(line)[fields])
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
exports.lvs = makeFunction('lvs')
|
|
31
|
+
exports.pvs = makeFunction('pvs')
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const mapValues = require('lodash/mapValues.js')
|
|
2
|
+
const { dirname } = require('path')
|
|
3
|
+
|
|
4
|
+
function formatVmBackup(backup) {
|
|
5
|
+
return {
|
|
6
|
+
disks:
|
|
7
|
+
backup.vhds === undefined
|
|
8
|
+
? []
|
|
9
|
+
: Object.keys(backup.vhds).map(vdiId => {
|
|
10
|
+
const vdi = backup.vdis[vdiId]
|
|
11
|
+
return {
|
|
12
|
+
id: `${dirname(backup._filename)}/${backup.vhds[vdiId]}`,
|
|
13
|
+
name: vdi.name_label,
|
|
14
|
+
uuid: vdi.uuid,
|
|
15
|
+
}
|
|
16
|
+
}),
|
|
17
|
+
|
|
18
|
+
id: backup.id,
|
|
19
|
+
jobId: backup.jobId,
|
|
20
|
+
mode: backup.mode,
|
|
21
|
+
scheduleId: backup.scheduleId,
|
|
22
|
+
size: backup.size,
|
|
23
|
+
timestamp: backup.timestamp,
|
|
24
|
+
vm: {
|
|
25
|
+
name_description: backup.vm.name_description,
|
|
26
|
+
name_label: backup.vm.name_label,
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// format all backups as returned by RemoteAdapter#listAllVmBackups()
|
|
32
|
+
exports.formatVmBackups = function formatVmBackups(backupsByVM) {
|
|
33
|
+
return mapValues(backupsByVM, backups => backups.map(formatVmBackup))
|
|
34
|
+
}
|