@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/Task.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
const CancelToken = require('promise-toolbox/CancelToken.js')
|
|
2
|
+
const Zone = require('node-zone')
|
|
3
|
+
|
|
4
|
+
const logAfterEnd = () => {
|
|
5
|
+
throw new Error('task has already ended')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const noop = Function.prototype
|
|
9
|
+
|
|
10
|
+
// Create a serializable object from an error.
|
|
11
|
+
//
|
|
12
|
+
// Otherwise some fields might be non-enumerable and missing from logs.
|
|
13
|
+
const serializeError = error =>
|
|
14
|
+
error instanceof Error
|
|
15
|
+
? {
|
|
16
|
+
...error, // Copy enumerable properties.
|
|
17
|
+
code: error.code,
|
|
18
|
+
message: error.message,
|
|
19
|
+
name: error.name,
|
|
20
|
+
stack: error.stack,
|
|
21
|
+
}
|
|
22
|
+
: error
|
|
23
|
+
|
|
24
|
+
const $$task = Symbol('@xen-orchestra/backups/Task')
|
|
25
|
+
|
|
26
|
+
class Task {
|
|
27
|
+
static get cancelToken() {
|
|
28
|
+
const task = Zone.current.data[$$task]
|
|
29
|
+
return task !== undefined ? task.#cancelToken : CancelToken.none
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static run(opts, fn) {
|
|
33
|
+
return new this(opts).run(fn, true)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static wrapFn(opts, fn) {
|
|
37
|
+
// compatibility with @decorateWith
|
|
38
|
+
if (typeof fn !== 'function') {
|
|
39
|
+
;[fn, opts] = [opts, fn]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return function () {
|
|
43
|
+
return Task.run(typeof opts === 'function' ? opts.apply(this, arguments) : opts, () => fn.apply(this, arguments))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#cancelToken
|
|
48
|
+
#id = Math.random().toString(36).slice(2)
|
|
49
|
+
#onLog
|
|
50
|
+
#zone
|
|
51
|
+
|
|
52
|
+
constructor({ name, data, onLog }) {
|
|
53
|
+
let parentCancelToken, parentId
|
|
54
|
+
if (onLog === undefined) {
|
|
55
|
+
const parent = Zone.current.data[$$task]
|
|
56
|
+
if (parent === undefined) {
|
|
57
|
+
onLog = noop
|
|
58
|
+
} else {
|
|
59
|
+
onLog = log => parent.#onLog(log)
|
|
60
|
+
parentCancelToken = parent.#cancelToken
|
|
61
|
+
parentId = parent.#id
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const zone = Zone.current.fork('@xen-orchestra/backups/Task')
|
|
66
|
+
zone.data[$$task] = this
|
|
67
|
+
this.#zone = zone
|
|
68
|
+
|
|
69
|
+
const { cancel, token } = CancelToken.source(parentCancelToken && [parentCancelToken])
|
|
70
|
+
this.#cancelToken = token
|
|
71
|
+
this.cancel = cancel
|
|
72
|
+
|
|
73
|
+
this.#onLog = onLog
|
|
74
|
+
|
|
75
|
+
this.#log('start', {
|
|
76
|
+
data,
|
|
77
|
+
message: name,
|
|
78
|
+
parentId,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
failure(error) {
|
|
83
|
+
this.#end('failure', serializeError(error))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
info(message, data) {
|
|
87
|
+
this.#log('info', { data, message })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Run a function in the context of this task
|
|
92
|
+
*
|
|
93
|
+
* In case of error, the task will be failed.
|
|
94
|
+
*
|
|
95
|
+
* @typedef Result
|
|
96
|
+
* @param {() => Result)} fn
|
|
97
|
+
* @param {boolean} last - Whether the task should succeed if there is no error
|
|
98
|
+
* @returns Result
|
|
99
|
+
*/
|
|
100
|
+
run(fn, last = false) {
|
|
101
|
+
return this.#zone.run(() => {
|
|
102
|
+
try {
|
|
103
|
+
const result = fn()
|
|
104
|
+
let then
|
|
105
|
+
if (result != null && typeof (then = result.then) === 'function') {
|
|
106
|
+
then.call(result, last && (value => this.success(value)), error => this.failure(error))
|
|
107
|
+
} else if (last) {
|
|
108
|
+
this.success(result)
|
|
109
|
+
}
|
|
110
|
+
return result
|
|
111
|
+
} catch (error) {
|
|
112
|
+
this.failure(error)
|
|
113
|
+
throw error
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
success(value) {
|
|
119
|
+
this.#end('success', value)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
warning(message, data) {
|
|
123
|
+
this.#log('warning', { data, message })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
wrapFn(fn, last) {
|
|
127
|
+
const task = this
|
|
128
|
+
return function () {
|
|
129
|
+
return task.run(() => fn.apply(this, arguments), last)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#end(status, result) {
|
|
134
|
+
this.#log('end', { result, status })
|
|
135
|
+
this.#onLog = logAfterEnd
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#log(event, props) {
|
|
139
|
+
this.#onLog({
|
|
140
|
+
...props,
|
|
141
|
+
event,
|
|
142
|
+
taskId: this.#id,
|
|
143
|
+
timestamp: Date.now(),
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
exports.Task = Task
|
|
148
|
+
|
|
149
|
+
for (const method of ['info', 'warning']) {
|
|
150
|
+
Task[method] = (...args) => Zone.current.data[$$task]?.[method](...args)
|
|
151
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const { asyncMap } = require('@xen-orchestra/async-map')
|
|
2
|
+
|
|
3
|
+
const { DIR_XO_POOL_METADATA_BACKUPS } = require('./RemoteAdapter.js')
|
|
4
|
+
const { forkStreamUnpipe } = require('./_forkStreamUnpipe.js')
|
|
5
|
+
const { formatFilenameDate } = require('./_filenameDate.js')
|
|
6
|
+
const { Task } = require('./Task.js')
|
|
7
|
+
|
|
8
|
+
const PATH_DB_DUMP = '/pool/xmldbdump'
|
|
9
|
+
exports.PATH_DB_DUMP = PATH_DB_DUMP
|
|
10
|
+
|
|
11
|
+
exports.PoolMetadataBackup = class PoolMetadataBackup {
|
|
12
|
+
constructor({ config, job, pool, remoteAdapters, schedule, settings }) {
|
|
13
|
+
this._config = config
|
|
14
|
+
this._job = job
|
|
15
|
+
this._pool = pool
|
|
16
|
+
this._remoteAdapters = remoteAdapters
|
|
17
|
+
this._schedule = schedule
|
|
18
|
+
this._settings = settings
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
_exportPoolMetadata() {
|
|
22
|
+
const xapi = this._pool.$xapi
|
|
23
|
+
return xapi.getResource(PATH_DB_DUMP, {
|
|
24
|
+
task: xapi.task_create('Export pool metadata'),
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async run() {
|
|
29
|
+
const timestamp = Date.now()
|
|
30
|
+
|
|
31
|
+
const { _job: job, _schedule: schedule, _pool: pool } = this
|
|
32
|
+
const poolDir = `${DIR_XO_POOL_METADATA_BACKUPS}/${schedule.id}/${pool.$id}`
|
|
33
|
+
const dir = `${poolDir}/${formatFilenameDate(timestamp)}`
|
|
34
|
+
|
|
35
|
+
const stream = await this._exportPoolMetadata()
|
|
36
|
+
const fileName = `${dir}/data`
|
|
37
|
+
|
|
38
|
+
const metadata = JSON.stringify(
|
|
39
|
+
{
|
|
40
|
+
jobId: job.id,
|
|
41
|
+
jobName: job.name,
|
|
42
|
+
pool,
|
|
43
|
+
poolMaster: pool.$master,
|
|
44
|
+
scheduleId: schedule.id,
|
|
45
|
+
scheduleName: schedule.name,
|
|
46
|
+
timestamp,
|
|
47
|
+
},
|
|
48
|
+
null,
|
|
49
|
+
2
|
|
50
|
+
)
|
|
51
|
+
const metaDataFileName = `${dir}/metadata.json`
|
|
52
|
+
|
|
53
|
+
await asyncMap(
|
|
54
|
+
Object.entries(this._remoteAdapters),
|
|
55
|
+
([remoteId, adapter]) =>
|
|
56
|
+
Task.run(
|
|
57
|
+
{
|
|
58
|
+
name: `Starting metadata backup for the pool (${pool.$id}) for the remote (${remoteId}). (${job.id})`,
|
|
59
|
+
data: {
|
|
60
|
+
id: remoteId,
|
|
61
|
+
type: 'remote',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
async () => {
|
|
65
|
+
// forkStreamUnpipe should be used in a sync way, do not wait for a promise before using it
|
|
66
|
+
await adapter.outputStream(fileName, forkStreamUnpipe(stream), { checksum: false })
|
|
67
|
+
await adapter.handler.outputFile(metaDataFileName, metadata, {
|
|
68
|
+
dirMode: this._config.dirMode,
|
|
69
|
+
})
|
|
70
|
+
await adapter.deleteOldMetadataBackups(poolDir, this._settings.retentionPoolMetadata)
|
|
71
|
+
}
|
|
72
|
+
).catch(() => {}) // errors are handled by logs
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
package/_VmBackup.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
const assert = require('assert')
|
|
2
|
+
const findLast = require('lodash/findLast.js')
|
|
3
|
+
const ignoreErrors = require('promise-toolbox/ignoreErrors.js')
|
|
4
|
+
const keyBy = require('lodash/keyBy.js')
|
|
5
|
+
const mapValues = require('lodash/mapValues.js')
|
|
6
|
+
const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
|
|
7
|
+
const { createLogger } = require('@xen-orchestra/log')
|
|
8
|
+
const { defer } = require('golike-defer')
|
|
9
|
+
const { formatDateTime } = require('@xen-orchestra/xapi')
|
|
10
|
+
|
|
11
|
+
const { DeltaBackupWriter } = require('./writers/DeltaBackupWriter.js')
|
|
12
|
+
const { DeltaReplicationWriter } = require('./writers/DeltaReplicationWriter.js')
|
|
13
|
+
const { exportDeltaVm } = require('./_deltaVm.js')
|
|
14
|
+
const { forkStreamUnpipe } = require('./_forkStreamUnpipe.js')
|
|
15
|
+
const { FullBackupWriter } = require('./writers/FullBackupWriter.js')
|
|
16
|
+
const { FullReplicationWriter } = require('./writers/FullReplicationWriter.js')
|
|
17
|
+
const { getOldEntries } = require('./_getOldEntries.js')
|
|
18
|
+
const { Task } = require('./Task.js')
|
|
19
|
+
const { watchStreamSize } = require('./_watchStreamSize.js')
|
|
20
|
+
|
|
21
|
+
const { debug, warn } = createLogger('xo:backups:VmBackup')
|
|
22
|
+
|
|
23
|
+
const asyncEach = async (iterable, fn, thisArg = iterable) => {
|
|
24
|
+
for (const item of iterable) {
|
|
25
|
+
await fn.call(thisArg, item)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const forkDeltaExport = deltaExport =>
|
|
30
|
+
Object.create(deltaExport, {
|
|
31
|
+
streams: {
|
|
32
|
+
value: mapValues(deltaExport.streams, forkStreamUnpipe),
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
exports.VmBackup = class VmBackup {
|
|
37
|
+
constructor({ config, getSnapshotNameLabel, job, remoteAdapters, remotes, schedule, settings, srs, vm }) {
|
|
38
|
+
this.config = config
|
|
39
|
+
this.job = job
|
|
40
|
+
this.remoteAdapters = remoteAdapters
|
|
41
|
+
this.remotes = remotes
|
|
42
|
+
this.scheduleId = schedule.id
|
|
43
|
+
this.timestamp = undefined
|
|
44
|
+
|
|
45
|
+
// VM currently backed up
|
|
46
|
+
this.vm = vm
|
|
47
|
+
const { tags } = this.vm
|
|
48
|
+
|
|
49
|
+
// VM (snapshot) that is really exported
|
|
50
|
+
this.exportedVm = undefined
|
|
51
|
+
|
|
52
|
+
this._fullVdisRequired = undefined
|
|
53
|
+
this._getSnapshotNameLabel = getSnapshotNameLabel
|
|
54
|
+
this._isDelta = job.mode === 'delta'
|
|
55
|
+
this._jobId = job.id
|
|
56
|
+
this._jobSnapshots = undefined
|
|
57
|
+
this._xapi = vm.$xapi
|
|
58
|
+
|
|
59
|
+
// Base VM for the export
|
|
60
|
+
this._baseVm = undefined
|
|
61
|
+
|
|
62
|
+
// Settings for this specific run (job, schedule, VM)
|
|
63
|
+
if (tags.includes('xo-memory-backup')) {
|
|
64
|
+
settings.checkpointSnapshot = true
|
|
65
|
+
}
|
|
66
|
+
if (tags.includes('xo-offline-backup')) {
|
|
67
|
+
settings.offlineSnapshot = true
|
|
68
|
+
}
|
|
69
|
+
this._settings = settings
|
|
70
|
+
|
|
71
|
+
// Create writers
|
|
72
|
+
{
|
|
73
|
+
const writers = new Set()
|
|
74
|
+
this._writers = writers
|
|
75
|
+
|
|
76
|
+
const [BackupWriter, ReplicationWriter] = this._isDelta
|
|
77
|
+
? [DeltaBackupWriter, DeltaReplicationWriter]
|
|
78
|
+
: [FullBackupWriter, FullReplicationWriter]
|
|
79
|
+
|
|
80
|
+
const allSettings = job.settings
|
|
81
|
+
|
|
82
|
+
Object.keys(remoteAdapters).forEach(remoteId => {
|
|
83
|
+
const targetSettings = {
|
|
84
|
+
...settings,
|
|
85
|
+
...allSettings[remoteId],
|
|
86
|
+
}
|
|
87
|
+
if (targetSettings.exportRetention !== 0) {
|
|
88
|
+
writers.add(new BackupWriter({ backup: this, remoteId, settings: targetSettings }))
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
srs.forEach(sr => {
|
|
92
|
+
const targetSettings = {
|
|
93
|
+
...settings,
|
|
94
|
+
...allSettings[sr.uuid],
|
|
95
|
+
}
|
|
96
|
+
if (targetSettings.copyRetention !== 0) {
|
|
97
|
+
writers.add(new ReplicationWriter({ backup: this, sr, settings: targetSettings }))
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// calls fn for each function, warns of any errors, and throws only if there are no writers left
|
|
104
|
+
async _callWriters(fn, warnMessage, parallel = true) {
|
|
105
|
+
const writers = this._writers
|
|
106
|
+
const n = writers.size
|
|
107
|
+
if (n === 0) {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (n === 1) {
|
|
111
|
+
const [writer] = writers
|
|
112
|
+
try {
|
|
113
|
+
await fn(writer)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
writers.delete(writer)
|
|
116
|
+
throw error
|
|
117
|
+
}
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await (parallel ? asyncMap : asyncEach)(writers, async function (writer) {
|
|
122
|
+
try {
|
|
123
|
+
await fn(writer)
|
|
124
|
+
} catch (error) {
|
|
125
|
+
this.delete(writer)
|
|
126
|
+
warn(warnMessage, { error, writer: writer.constructor.name })
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
if (writers.size === 0) {
|
|
130
|
+
throw new Error('all targets have failed, step: ' + warnMessage)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ensure the VM itself does not have any backup metadata which would be
|
|
135
|
+
// copied on manual snapshots and interfere with the backup jobs
|
|
136
|
+
async _cleanMetadata() {
|
|
137
|
+
const { vm } = this
|
|
138
|
+
if ('xo:backup:job' in vm.other_config) {
|
|
139
|
+
await vm.update_other_config({
|
|
140
|
+
'xo:backup:datetime': null,
|
|
141
|
+
'xo:backup:deltaChainLength': null,
|
|
142
|
+
'xo:backup:exported': null,
|
|
143
|
+
'xo:backup:job': null,
|
|
144
|
+
'xo:backup:schedule': null,
|
|
145
|
+
'xo:backup:vm': null,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async _snapshot() {
|
|
151
|
+
const { vm } = this
|
|
152
|
+
const xapi = this._xapi
|
|
153
|
+
|
|
154
|
+
const settings = this._settings
|
|
155
|
+
|
|
156
|
+
const doSnapshot =
|
|
157
|
+
this._isDelta || (!settings.offlineBackup && vm.power_state === 'Running') || settings.snapshotRetention !== 0
|
|
158
|
+
if (doSnapshot) {
|
|
159
|
+
await Task.run({ name: 'snapshot' }, async () => {
|
|
160
|
+
if (!settings.bypassVdiChainsCheck) {
|
|
161
|
+
await vm.$assertHealthyVdiChains()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const snapshotRef = await vm[settings.checkpointSnapshot ? '$checkpoint' : '$snapshot']({
|
|
165
|
+
name_label: this._getSnapshotNameLabel(vm),
|
|
166
|
+
})
|
|
167
|
+
this.timestamp = Date.now()
|
|
168
|
+
|
|
169
|
+
await xapi.setFieldEntries('VM', snapshotRef, 'other_config', {
|
|
170
|
+
'xo:backup:datetime': formatDateTime(this.timestamp),
|
|
171
|
+
'xo:backup:job': this._jobId,
|
|
172
|
+
'xo:backup:schedule': this.scheduleId,
|
|
173
|
+
'xo:backup:vm': vm.uuid,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
this.exportedVm = await xapi.getRecord('VM', snapshotRef)
|
|
177
|
+
|
|
178
|
+
return this.exportedVm.uuid
|
|
179
|
+
})
|
|
180
|
+
} else {
|
|
181
|
+
this.exportedVm = vm
|
|
182
|
+
this.timestamp = Date.now()
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async _copyDelta() {
|
|
187
|
+
const { exportedVm } = this
|
|
188
|
+
const baseVm = this._baseVm
|
|
189
|
+
const fullVdisRequired = this._fullVdisRequired
|
|
190
|
+
|
|
191
|
+
const isFull = fullVdisRequired === undefined || fullVdisRequired.size !== 0
|
|
192
|
+
|
|
193
|
+
await this._callWriters(writer => writer.prepare({ isFull }), 'writer.prepare()')
|
|
194
|
+
|
|
195
|
+
const deltaExport = await exportDeltaVm(exportedVm, baseVm, {
|
|
196
|
+
fullVdisRequired,
|
|
197
|
+
})
|
|
198
|
+
const sizeContainers = mapValues(deltaExport.streams, stream => watchStreamSize(stream))
|
|
199
|
+
|
|
200
|
+
const timestamp = Date.now()
|
|
201
|
+
|
|
202
|
+
await this._callWriters(
|
|
203
|
+
writer =>
|
|
204
|
+
writer.transfer({
|
|
205
|
+
deltaExport: forkDeltaExport(deltaExport),
|
|
206
|
+
sizeContainers,
|
|
207
|
+
timestamp,
|
|
208
|
+
}),
|
|
209
|
+
'writer.transfer()'
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
this._baseVm = exportedVm
|
|
213
|
+
|
|
214
|
+
if (baseVm !== undefined) {
|
|
215
|
+
await exportedVm.update_other_config(
|
|
216
|
+
'xo:backup:deltaChainLength',
|
|
217
|
+
String(+(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1)
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// not the case if offlineBackup
|
|
222
|
+
if (exportedVm.is_a_snapshot) {
|
|
223
|
+
await exportedVm.update_other_config('xo:backup:exported', 'true')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const size = Object.values(sizeContainers).reduce((sum, { size }) => sum + size, 0)
|
|
227
|
+
const end = Date.now()
|
|
228
|
+
const duration = end - timestamp
|
|
229
|
+
debug('transfer complete', {
|
|
230
|
+
duration,
|
|
231
|
+
speed: duration !== 0 ? (size * 1e3) / 1024 / 1024 / duration : 0,
|
|
232
|
+
size,
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async _copyFull() {
|
|
239
|
+
const { compression } = this.job
|
|
240
|
+
const stream = await this._xapi.VM_export(this.exportedVm.$ref, {
|
|
241
|
+
compress: Boolean(compression) && (compression === 'native' ? 'gzip' : 'zstd'),
|
|
242
|
+
useSnapshot: false,
|
|
243
|
+
})
|
|
244
|
+
const sizeContainer = watchStreamSize(stream)
|
|
245
|
+
|
|
246
|
+
const timestamp = Date.now()
|
|
247
|
+
|
|
248
|
+
await this._callWriters(
|
|
249
|
+
writer =>
|
|
250
|
+
writer.run({
|
|
251
|
+
sizeContainer,
|
|
252
|
+
stream: forkStreamUnpipe(stream),
|
|
253
|
+
timestamp,
|
|
254
|
+
}),
|
|
255
|
+
'writer.run()'
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
const { size } = sizeContainer
|
|
259
|
+
const end = Date.now()
|
|
260
|
+
const duration = end - timestamp
|
|
261
|
+
debug('transfer complete', {
|
|
262
|
+
duration,
|
|
263
|
+
speed: duration !== 0 ? (size * 1e3) / 1024 / 1024 / duration : 0,
|
|
264
|
+
size,
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async _fetchJobSnapshots() {
|
|
269
|
+
const jobId = this._jobId
|
|
270
|
+
const vmRef = this.vm.$ref
|
|
271
|
+
const xapi = this._xapi
|
|
272
|
+
|
|
273
|
+
const snapshotsRef = await xapi.getField('VM', vmRef, 'snapshots')
|
|
274
|
+
const snapshotsOtherConfig = await asyncMap(snapshotsRef, ref => xapi.getField('VM', ref, 'other_config'))
|
|
275
|
+
|
|
276
|
+
const snapshots = []
|
|
277
|
+
snapshotsOtherConfig.forEach((other_config, i) => {
|
|
278
|
+
if (other_config['xo:backup:job'] === jobId) {
|
|
279
|
+
snapshots.push({ other_config, $ref: snapshotsRef[i] })
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
snapshots.sort((a, b) => (a.other_config['xo:backup:datetime'] < b.other_config['xo:backup:datetime'] ? -1 : 1))
|
|
283
|
+
this._jobSnapshots = snapshots
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async _removeUnusedSnapshots() {
|
|
287
|
+
// TODO: handle all schedules (no longer existing schedules default to 0 retention)
|
|
288
|
+
|
|
289
|
+
const { scheduleId } = this
|
|
290
|
+
const scheduleSnapshots = this._jobSnapshots.filter(_ => _.other_config['xo:backup:schedule'] === scheduleId)
|
|
291
|
+
|
|
292
|
+
const baseVmRef = this._baseVm?.$ref
|
|
293
|
+
const xapi = this._xapi
|
|
294
|
+
await asyncMap(getOldEntries(this._settings.snapshotRetention, scheduleSnapshots), ({ $ref }) => {
|
|
295
|
+
if ($ref !== baseVmRef) {
|
|
296
|
+
return xapi.VM_destroy($ref)
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async _selectBaseVm() {
|
|
302
|
+
const xapi = this._xapi
|
|
303
|
+
|
|
304
|
+
let baseVm = findLast(this._jobSnapshots, _ => 'xo:backup:exported' in _.other_config)
|
|
305
|
+
if (baseVm === undefined) {
|
|
306
|
+
debug('no base VM found')
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const fullInterval = this._settings.fullInterval
|
|
311
|
+
const deltaChainLength = +(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1
|
|
312
|
+
if (!(fullInterval === 0 || fullInterval > deltaChainLength)) {
|
|
313
|
+
debug('not using base VM becaust fullInterval reached')
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const srcVdis = keyBy(await xapi.getRecords('VDI', await this.vm.$getDisks()), '$ref')
|
|
318
|
+
|
|
319
|
+
// resolve full record
|
|
320
|
+
baseVm = await xapi.getRecord('VM', baseVm.$ref)
|
|
321
|
+
|
|
322
|
+
const baseUuidToSrcVdi = new Map()
|
|
323
|
+
await asyncMap(await baseVm.$getDisks(), async baseRef => {
|
|
324
|
+
const snapshotOf = await xapi.getField('VDI', baseRef, 'snapshot_of')
|
|
325
|
+
const srcVdi = srcVdis[snapshotOf]
|
|
326
|
+
if (srcVdi !== undefined) {
|
|
327
|
+
baseUuidToSrcVdi.set(await xapi.getField('VDI', baseRef, 'uuid'), srcVdi)
|
|
328
|
+
} else {
|
|
329
|
+
debug('no base VDI found', {
|
|
330
|
+
vdi: srcVdi.uuid,
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
const presentBaseVdis = new Map(baseUuidToSrcVdi)
|
|
336
|
+
await this._callWriters(
|
|
337
|
+
writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis, baseVm),
|
|
338
|
+
'writer.checkBaseVdis()',
|
|
339
|
+
false
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
const fullVdisRequired = new Set()
|
|
343
|
+
baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
|
|
344
|
+
if (presentBaseVdis.has(baseUuid)) {
|
|
345
|
+
debug('found base VDI', {
|
|
346
|
+
base: baseUuid,
|
|
347
|
+
vdi: srcVdi.uuid,
|
|
348
|
+
})
|
|
349
|
+
} else {
|
|
350
|
+
debug('missing base VDI', {
|
|
351
|
+
base: baseUuid,
|
|
352
|
+
vdi: srcVdi.uuid,
|
|
353
|
+
})
|
|
354
|
+
fullVdisRequired.add(srcVdi.uuid)
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
this._baseVm = baseVm
|
|
359
|
+
this._fullVdisRequired = fullVdisRequired
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
run = defer(this.run)
|
|
363
|
+
async run($defer) {
|
|
364
|
+
const settings = this._settings
|
|
365
|
+
assert(
|
|
366
|
+
!settings.offlineBackup || settings.snapshotRetention === 0,
|
|
367
|
+
'offlineBackup is not compatible with snapshotRetention'
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
await this._callWriters(async writer => {
|
|
371
|
+
await writer.beforeBackup()
|
|
372
|
+
$defer(() => writer.afterBackup())
|
|
373
|
+
}, 'writer.beforeBackup()')
|
|
374
|
+
|
|
375
|
+
await this._fetchJobSnapshots()
|
|
376
|
+
|
|
377
|
+
if (this._isDelta) {
|
|
378
|
+
await this._selectBaseVm()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
await this._cleanMetadata()
|
|
382
|
+
await this._removeUnusedSnapshots()
|
|
383
|
+
|
|
384
|
+
const { vm } = this
|
|
385
|
+
const isRunning = vm.power_state === 'Running'
|
|
386
|
+
const startAfter = isRunning && (settings.offlineBackup ? 'backup' : settings.offlineSnapshot && 'snapshot')
|
|
387
|
+
if (startAfter) {
|
|
388
|
+
await vm.$callAsync('clean_shutdown')
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
await this._snapshot()
|
|
393
|
+
if (startAfter === 'snapshot') {
|
|
394
|
+
ignoreErrors.call(vm.$callAsync('start', false, false))
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (this._writers.size !== 0) {
|
|
398
|
+
await (this._isDelta ? this._copyDelta() : this._copyFull())
|
|
399
|
+
}
|
|
400
|
+
} finally {
|
|
401
|
+
if (startAfter) {
|
|
402
|
+
ignoreErrors.call(vm.$callAsync('start', false, false))
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
await this._fetchJobSnapshots()
|
|
406
|
+
await this._removeUnusedSnapshots()
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const { asyncMap } = require('@xen-orchestra/async-map')
|
|
2
|
+
|
|
3
|
+
const { DIR_XO_CONFIG_BACKUPS } = require('./RemoteAdapter.js')
|
|
4
|
+
const { formatFilenameDate } = require('./_filenameDate.js')
|
|
5
|
+
const { Task } = require('./Task.js')
|
|
6
|
+
|
|
7
|
+
exports.XoMetadataBackup = class XoMetadataBackup {
|
|
8
|
+
constructor({ config, job, remoteAdapters, schedule, settings }) {
|
|
9
|
+
this._config = config
|
|
10
|
+
this._job = job
|
|
11
|
+
this._remoteAdapters = remoteAdapters
|
|
12
|
+
this._schedule = schedule
|
|
13
|
+
this._settings = settings
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async run() {
|
|
17
|
+
const timestamp = Date.now()
|
|
18
|
+
|
|
19
|
+
const { _job: job, _schedule: schedule } = this
|
|
20
|
+
const scheduleDir = `${DIR_XO_CONFIG_BACKUPS}/${schedule.id}`
|
|
21
|
+
const dir = `${scheduleDir}/${formatFilenameDate(timestamp)}`
|
|
22
|
+
|
|
23
|
+
const data = job.xoMetadata
|
|
24
|
+
const fileName = `${dir}/data.json`
|
|
25
|
+
|
|
26
|
+
const metadata = JSON.stringify(
|
|
27
|
+
{
|
|
28
|
+
jobId: job.id,
|
|
29
|
+
jobName: job.name,
|
|
30
|
+
scheduleId: schedule.id,
|
|
31
|
+
scheduleName: schedule.name,
|
|
32
|
+
timestamp,
|
|
33
|
+
},
|
|
34
|
+
null,
|
|
35
|
+
2
|
|
36
|
+
)
|
|
37
|
+
const metaDataFileName = `${dir}/metadata.json`
|
|
38
|
+
|
|
39
|
+
await asyncMap(
|
|
40
|
+
Object.entries(this._remoteAdapters),
|
|
41
|
+
([remoteId, adapter]) =>
|
|
42
|
+
Task.run(
|
|
43
|
+
{
|
|
44
|
+
name: `Starting XO metadata backup for the remote (${remoteId}). (${job.id})`,
|
|
45
|
+
data: {
|
|
46
|
+
id: remoteId,
|
|
47
|
+
type: 'remote',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
async () => {
|
|
51
|
+
const handler = adapter.handler
|
|
52
|
+
const dirMode = this._config.dirMode
|
|
53
|
+
await handler.outputFile(fileName, data, { dirMode })
|
|
54
|
+
await handler.outputFile(metaDataFileName, metadata, {
|
|
55
|
+
dirMode,
|
|
56
|
+
})
|
|
57
|
+
await adapter.deleteOldMetadataBackups(scheduleDir, this._settings.retentionXoMetadata)
|
|
58
|
+
}
|
|
59
|
+
).catch(() => {}) // errors are handled by logs
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
}
|
package/_backupType.js
ADDED