@xen-orchestra/backups 0.68.2 → 0.69.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,157 @@
1
+ import { asyncEach } from '@vates/async-each'
2
+ import { getOldEntries } from '../../_getOldEntries.mjs'
3
+ import { compareTimestamp } from '../../RemoteAdapter.mjs'
4
+ import { createLogger } from '@xen-orchestra/log'
5
+
6
+ const { debug } = createLogger('xo:backups:AbstractAggregatedRemoteWriter')
7
+
8
+ export const DEFAULT_AGGREGATED_REMOTE_DELETE_CONCURRENCY = 2
9
+ export class AbstractAggregatedRemoteWriter {
10
+ #BackupWriter
11
+ #adapters
12
+ get adapters() {
13
+ return this.#adapters
14
+ }
15
+
16
+ mainWriter
17
+ #props
18
+ get props() {
19
+ return this.#props
20
+ }
21
+ #oldBackups
22
+
23
+ #writers = []
24
+ get writers() {
25
+ return this.#writers
26
+ }
27
+ constructor({ adapters, BackupWriter, ...props }) {
28
+ debug('instantiate AggregatedFullRemoteWriter')
29
+ if (Object.keys(adapters).length < 1) {
30
+ throw new Error('AggregatedRemoteWriter need at least one backup repository to work ')
31
+ }
32
+ this.#props = props
33
+ this.#adapters = adapters
34
+ this.#BackupWriter = BackupWriter
35
+ }
36
+
37
+ getWriterByAdapter(adapter) {
38
+ for (const writer of this.#writers) {
39
+ if (writer._adapter === adapter) {
40
+ return writer
41
+ }
42
+ }
43
+ throw new Error("Can't find writer for adapter")
44
+ }
45
+
46
+ /**
47
+ * select the best adapter candidate to write this backup
48
+ * if all the adapter infos expose an available info use the one with the more free space
49
+ * if none of the adapter exposes this info : choose at random
50
+ * if mixed : throw an error
51
+ * @returns {RemoteAdapter}
52
+ */
53
+ async computeAdapterCandidate() {
54
+ const adapters = Object.values(this.#adapters)
55
+ const infos = await Promise.all(
56
+ adapters.map(async adapter => {
57
+ const infos = await adapter._handler.getInfo()
58
+ return { adapter, infos }
59
+ })
60
+ )
61
+ const withLimit = infos.filter(({ infos: { available } }) => available > 0).length
62
+ if (withLimit !== 0 && withLimit !== infos.length) {
63
+ throw new Error(`an Aggregate remote can't use a mix of limited and unlimited storages`)
64
+ }
65
+ let candidates
66
+ if (withLimit === 0) {
67
+ debug('no storage limit , choose at random')
68
+ candidates = Object.values(this.adapters)
69
+ } else {
70
+ debug('storage limit , choose at random between those with the max space avilable')
71
+ let maxSpace = 0
72
+ infos.forEach(({ infos: { available } }) => {
73
+ if (available > maxSpace) {
74
+ maxSpace = available
75
+ }
76
+ })
77
+
78
+ // candidates are the adapter with the same free space , equal to the max free space
79
+ candidates = infos.filter(({ infos }) => infos.available === maxSpace).map(({ adapter }) => adapter)
80
+ }
81
+ const adapterCandidate = candidates[Math.floor(Math.random() * candidates.length)]
82
+ debug('adapter candidate is selected ', adapterCandidate._handler._remote.id)
83
+ return adapterCandidate
84
+ }
85
+
86
+ async setupWriters() {
87
+ debug('Setup writers ')
88
+ const settings = this.#props.settings
89
+ const allSettings = this.#props.job.settings
90
+
91
+ Object.entries(this.#adapters).forEach(([remoteId, adapter]) => {
92
+ const targetSettings = {
93
+ ...settings,
94
+ ...allSettings[remoteId],
95
+ skipDeleteOldEntries: true, // delete is handled globally
96
+ }
97
+ if (targetSettings.exportRetention !== 0) {
98
+ const writer = new this.#BackupWriter({
99
+ ...this.#props,
100
+ adapter,
101
+ remoteId,
102
+ settings: targetSettings,
103
+ })
104
+ this.#writers.push(writer)
105
+ }
106
+ })
107
+ }
108
+
109
+ async getEntriesPerAdapter(adapter) {
110
+ const scheduleId = this.#props.scheduleId
111
+ const vmUuid = this.#props.vmUuid
112
+ return (await adapter.listVmBackups(vmUuid, _ => _.scheduleId === scheduleId)).map(entry => ({
113
+ ...entry,
114
+ adapter,
115
+ }))
116
+ }
117
+
118
+ async setOldBackups() {
119
+ const entriesPerAdapter = await Promise.all(
120
+ Object.values(this.#adapters).map(adapter => this.getEntriesPerAdapter(adapter))
121
+ )
122
+ const entries = entriesPerAdapter
123
+ .flat(1)
124
+ .filter(_ => !!_)
125
+ .sort(compareTimestamp)
126
+
127
+ const settings = this.#props.settings
128
+ this.#oldBackups = getOldEntries(settings.exportRetention - 1, entries, {
129
+ longTermRetention: settings.longTermRetention,
130
+ timezone: settings.timezone,
131
+ })
132
+ debug(`got ${this.#oldBackups.length} old backup(s) to delete `)
133
+ }
134
+
135
+ async deleteOldBackupsOnAdapter(adapter, backup) {
136
+ throw new Error('not implemented')
137
+ }
138
+
139
+ async deleteOldBackups() {
140
+ const byAdapters = new Map()
141
+ this.#oldBackups.forEach(({ adapter, ...backup }) => {
142
+ const current = byAdapters.has(adapter) ? byAdapters.get(adapter) : []
143
+ current.push(backup)
144
+ byAdapters.set(adapter, current)
145
+ })
146
+ await asyncEach(
147
+ byAdapters.entries(),
148
+ async ([adapter, backups]) => {
149
+ debug(`delete ${backups.length} old backup(s) to delete from ${adapter._handler._remote.id}`)
150
+ await this.deleteOldBackupsOnAdapter(adapter, backups)
151
+ },
152
+ {
153
+ concurrency: this.#props.deleteConcurrency ?? DEFAULT_AGGREGATED_REMOTE_DELETE_CONCURRENCY,
154
+ }
155
+ )
156
+ }
157
+ }
@@ -0,0 +1,114 @@
1
+ import { getOldEntries } from '../../_getOldEntries.mjs'
2
+ import { createLogger } from '@xen-orchestra/log'
3
+ import { compareReplicatedVmDatetime, listReplicatedVms } from './_listReplicatedVms.mjs'
4
+ import { asyncMapSettled } from '@xen-orchestra/async-map'
5
+
6
+ const { debug } = createLogger('xo:backups:AbstractAggregatedXapiWriter')
7
+ export class AbstractAggregatedXapiWriter {
8
+ #ReplicationWriter
9
+ #storageRepositories
10
+
11
+ #writers = []
12
+ get writers() {
13
+ return this.#writers
14
+ }
15
+ mainWriter
16
+ #props
17
+ get props() {
18
+ return this.#props
19
+ }
20
+ #oldVmReplicaList = []
21
+
22
+ constructor({ srs, ReplicationWriter, ...props }) {
23
+ debug('instantiate AbstractAggregatedXapiWriter')
24
+ if (srs.length < 1) {
25
+ throw new Error('AggregatedXapiWriter need at least one storage repository to work ')
26
+ }
27
+ this.#props = props
28
+ this.#ReplicationWriter = ReplicationWriter
29
+ this.#storageRepositories = srs
30
+ }
31
+
32
+ getWriterBySr(storageRepository) {
33
+ for (const writer of this.#writers) {
34
+ if (writer._sr === storageRepository) {
35
+ return writer
36
+ }
37
+ }
38
+ throw new Error("Can't find writer for storage repository")
39
+ }
40
+
41
+ async computeSRCandidate() {
42
+ let maxSpace = 0
43
+ this.#storageRepositories.forEach(({ physical_utilisation, physical_size }) => {
44
+ if (physical_size - physical_utilisation > maxSpace) {
45
+ maxSpace = physical_size - physical_utilisation
46
+ }
47
+ })
48
+ debug(`computeSRCandidate found the max free space ${maxSpace} for vm${this.#props.vmUuid}`)
49
+
50
+ const srCandidates = this.#storageRepositories.filter(
51
+ ({ physical_utilisation, physical_size }) => maxSpace === physical_size - physical_utilisation
52
+ )
53
+ // one at random from the sr with the most free space
54
+ const candidate = srCandidates[Math.floor(Math.random() * srCandidates.length)]
55
+
56
+ debug(
57
+ `computeSRCandidate found ${srCandidates.length} sr with ${maxSpace} free size, and chose ${candidate.name_label} for vm${this.#props.vmUuid}`
58
+ )
59
+ return candidate
60
+ }
61
+
62
+ setOldReplicaList() {
63
+ debug(`setOldReplicaList for vm ${this.#props.vmUuid}`)
64
+ const scheduleId = this.#props.scheduleId
65
+ const vmUuid = this.#props.vmUuid
66
+ const settings = this.#props.settings
67
+
68
+ const replicatedVms = this.#storageRepositories
69
+ .map(sr => {
70
+ return listReplicatedVms(sr.$xapi, scheduleId, sr.uuid, vmUuid)
71
+ })
72
+ .flat(1)
73
+ .filter(_ => !!_)
74
+ .sort(compareReplicatedVmDatetime)
75
+
76
+ this.#oldVmReplicaList = getOldEntries(settings.copyRetention - 1, replicatedVms)
77
+
78
+ debug(
79
+ `setOldReplicaList found ${replicatedVms.length} replica, and will delete ${this.#oldVmReplicaList.length} (retention ${settings.copyRetention}) for vm ${this.#props.vmUuid}`
80
+ )
81
+ }
82
+
83
+ async deleteOldReplicas() {
84
+ debug(`deleteOldReplicas will delete ${this.#oldVmReplicaList.length} replicated vms for vm ${this.#props.vmUuid}`)
85
+ // destroy the VM on the right xapi
86
+ await asyncMapSettled(this.#oldVmReplicaList, async vm => {
87
+ debug('will delete old replica', vm.name_label)
88
+ await vm.$xapi.VM_destroy(vm.$ref)
89
+ })
90
+ debug(`deleteOldReplicas deleted ${this.#oldVmReplicaList.length} replicated vms for vm ${this.#props.vmUuid}`)
91
+ }
92
+
93
+ async setupWriters() {
94
+ debug('Setup writers ')
95
+ const settings = this.#props.settings
96
+ const allSettings = this.#props.job.settings
97
+
98
+ this.#storageRepositories.forEach(storageRepository => {
99
+ const targetSettings = {
100
+ ...settings,
101
+ ...allSettings[storageRepository.uuid],
102
+ skipDeleteOldEntries: true, // delete is handled globally
103
+ }
104
+ if (targetSettings.copyRetention !== 0) {
105
+ const writer = new this.#ReplicationWriter({
106
+ ...this.#props,
107
+ sr: storageRepository,
108
+ settings: targetSettings,
109
+ })
110
+ this.#writers.push(writer)
111
+ }
112
+ })
113
+ }
114
+ }
@@ -5,10 +5,18 @@ const getReplicatedVmDatetime = vm => {
5
5
  return datetime
6
6
  }
7
7
 
8
- const compareReplicatedVmDatetime = (a, b) => (getReplicatedVmDatetime(a) < getReplicatedVmDatetime(b) ? -1 : 1)
8
+ export const compareReplicatedVmDatetime = (a, b) => (getReplicatedVmDatetime(a) < getReplicatedVmDatetime(b) ? -1 : 1)
9
9
 
10
+ /**
11
+ *
12
+ * @param {Xapi} xapi
13
+ * @param {string} scheduleOrJobId
14
+ * @param {string} srUuid
15
+ * @param {string} vmUuid
16
+ * @returns {Array<XoVm>}
17
+ */
10
18
  export function listReplicatedVms(xapi, scheduleOrJobId, srUuid, vmUuid) {
11
- const { all } = xapi.objects
19
+ const all = xapi.objects.indexes.type.VM
12
20
  const vms = {}
13
21
  for (const key in all) {
14
22
  const object = all[key]
@@ -0,0 +1,317 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {import('./RemoteDisk.mjs').RemoteDisk} RemoteDisk
5
+ * @typedef {import('@xen-orchestra/disk-transform').FileAccessor} FileAccessor
6
+ */
7
+
8
+ import assert from 'assert'
9
+ import { createLogger } from '@xen-orchestra/log'
10
+
11
+ import { basename, dirname } from 'path'
12
+ import { asyncEach } from '@vates/async-each'
13
+
14
+ // @ts-ignore
15
+ const { warn } = createLogger('remote-disk:merge')
16
+
17
+ /**
18
+ * @typedef {Object} MergeState
19
+ * @property {{ uuid: string }} child
20
+ * @property {{ uuid: string }} parent
21
+ * @property {number} currentBlock
22
+ * @property {number} mergedDataSize
23
+ * @property {'mergeBlocks' | 'cleanup'} step
24
+ * @property {number} diskSize
25
+ */
26
+
27
+ export class MergeRemoteDisk {
28
+ /**
29
+ * @type {MergeState}
30
+ */
31
+ #state = {
32
+ child: { uuid: '0' },
33
+ parent: { uuid: '0' },
34
+ currentBlock: 0,
35
+ mergedDataSize: 0,
36
+ step: 'mergeBlocks',
37
+ diskSize: 0,
38
+ }
39
+
40
+ /**
41
+ * @type {string}
42
+ */
43
+ #statePath = ''
44
+
45
+ /**
46
+ * @type {boolean}
47
+ */
48
+ #isResuming
49
+
50
+ /**
51
+ * @type {Logger | Function}
52
+ */
53
+ #logInfo
54
+
55
+ /**
56
+ * @type {number}
57
+ */
58
+ #mergeBlockConcurrency
59
+
60
+ /**
61
+ * @type {Function}
62
+ */
63
+ #onProgress
64
+
65
+ /**
66
+ * @type {boolean}
67
+ */
68
+ #removeUnused
69
+
70
+ /**
71
+ * @type {number}
72
+ */
73
+ #writeStateDelay
74
+
75
+ /**
76
+ * @type {number}
77
+ */
78
+ #lastStateWrittenAt
79
+
80
+ /**
81
+ * @type {FileAccessor}
82
+ */
83
+ #handler
84
+
85
+ /**
86
+ * @param {FileAccessor} handler
87
+ * @param {Object} params
88
+ * @param {Function} params.onProgress
89
+ * @param {Logger | Function} params.logInfo
90
+ * @param {boolean} params.removeUnused
91
+ * @param {number} params.mergeBlockConcurrency
92
+ * @param {number} params.writeStateDelay
93
+ */
94
+ constructor(
95
+ handler,
96
+ {
97
+ onProgress = () => {},
98
+ logInfo = () => {},
99
+ removeUnused = false,
100
+ mergeBlockConcurrency = 2,
101
+ writeStateDelay = 10e3,
102
+ }
103
+ ) {
104
+ this.#handler = handler
105
+ this.#logInfo = logInfo
106
+ this.#onProgress = onProgress
107
+ this.#removeUnused = removeUnused
108
+ this.#mergeBlockConcurrency = mergeBlockConcurrency
109
+ this.#writeStateDelay = writeStateDelay
110
+
111
+ this.#isResuming = false
112
+ this.#lastStateWrittenAt = 0
113
+ }
114
+
115
+ /**
116
+ * @param {RemoteDisk} parentDisk
117
+ * @returns {Promise<boolean>} isResuming
118
+ */
119
+ async isResuming(parentDisk) {
120
+ try {
121
+ await this.#handler.readFile(
122
+ dirname(parentDisk.getPath()) + '/.' + basename(parentDisk.getPath()) + '.merge.json'
123
+ )
124
+ return true
125
+ } catch (error) {
126
+ return false
127
+ }
128
+ }
129
+
130
+ /**
131
+ * @param {RemoteDisk} parentDisk
132
+ * @param {RemoteDisk} childDisk
133
+ */
134
+ async merge(parentDisk, childDisk) {
135
+ this.#statePath = dirname(parentDisk.getPath()) + '/.' + basename(parentDisk.getPath()) + '.merge.json'
136
+
137
+ try {
138
+ const mergeStateContent = await this.#handler.readFile(this.#statePath)
139
+ this.#state = JSON.parse(mergeStateContent)
140
+
141
+ // work-around a bug introduce in 97d94b795
142
+ //
143
+ // currentBlock could be `null` due to the JSON.stringify of a `NaN` value
144
+ if (this.#state?.currentBlock === null) this.#state.currentBlock = 0
145
+ this.#isResuming = true
146
+ } catch (error) {
147
+ // @ts-ignore
148
+ if (error.code !== 'ENOENT') {
149
+ warn('problem while checking the merge state', { error })
150
+ }
151
+ }
152
+
153
+ try {
154
+ /* eslint-disable no-fallthrough */
155
+ switch (this.#state?.step ?? 'mergeBlocks') {
156
+ case 'mergeBlocks':
157
+ await this.#step_mergeBlocks(parentDisk, childDisk)
158
+ case 'cleanup':
159
+ await this.#step_cleanup(parentDisk, childDisk)
160
+ return this.#cleanup(parentDisk, childDisk, true)
161
+ default:
162
+ warn(`Step ${this.#state?.step} is unknown`, { state: this.#state })
163
+ }
164
+ /* eslint-enable no-fallthrough */
165
+ } catch (error) {
166
+ await this.#cleanup(parentDisk, childDisk, false)
167
+ throw error
168
+ }
169
+ }
170
+
171
+ async #writeState() {
172
+ try {
173
+ await this.#handler.writeFile(this.#statePath, JSON.stringify(this.#state), { flags: 'w' })
174
+ } catch (err) {
175
+ warn('failed to write merge state', { error: err })
176
+ }
177
+ }
178
+
179
+ async #writeStateThrottled() {
180
+ const now = Date.now()
181
+ if (now - this.#lastStateWrittenAt > this.#writeStateDelay) {
182
+ this.#lastStateWrittenAt = now
183
+ await this.#writeState()
184
+ }
185
+ }
186
+
187
+ /**
188
+ * @param {RemoteDisk} parentDisk
189
+ * @param {RemoteDisk} childDisk
190
+ */
191
+ async #step_mergeBlocks(parentDisk, childDisk) {
192
+ const getMaxBlockCount = childDisk.getMaxBlockCount()
193
+
194
+ if (this.#isResuming) {
195
+ const alreadyMergedBlocks = []
196
+ for (let blockId = 0; blockId < this.#state.currentBlock; blockId++) {
197
+ if (childDisk.hasBlock(blockId)) {
198
+ alreadyMergedBlocks.push(blockId)
199
+ }
200
+ }
201
+
202
+ parentDisk.setAllocatedBlocks(alreadyMergedBlocks)
203
+ } else {
204
+ this.#state.child = { uuid: childDisk.getUuid() ?? 0 }
205
+ this.#state.parent = { uuid: parentDisk.getUuid() ?? 0 }
206
+
207
+ // Finds first allocated block for the 2 following loops
208
+ while (this.#state.currentBlock < getMaxBlockCount && !childDisk.hasBlock(this.#state.currentBlock)) {
209
+ ++this.#state.currentBlock
210
+ }
211
+ await this.#writeState()
212
+ }
213
+
214
+ await this.#mergeBlocks(parentDisk, childDisk)
215
+ await parentDisk.flushMetadata(childDisk)
216
+ parentDisk.mergeMetadata(childDisk)
217
+ }
218
+
219
+ /**
220
+ * @param {RemoteDisk} parentDisk
221
+ * @param {RemoteDisk} childDisk
222
+ */
223
+ async #mergeBlocks(parentDisk, childDisk) {
224
+ this.#mergeBlockConcurrency =
225
+ (await parentDisk.canMergeConcurently()) && (await childDisk.canMergeConcurently())
226
+ ? this.#mergeBlockConcurrency
227
+ : 1
228
+
229
+ const toMerge = []
230
+ for (let block = this.#state.currentBlock; block < childDisk.getMaxBlockCount(); block++) {
231
+ if (childDisk.hasBlock(block)) {
232
+ toMerge.push(block)
233
+ }
234
+ }
235
+
236
+ const nBlocks = toMerge.length
237
+ this.#onProgress({ total: nBlocks, done: 0 })
238
+
239
+ const merging = new Set()
240
+ let counter = 0
241
+ await asyncEach(
242
+ toMerge,
243
+ async blockId => {
244
+ merging.add(blockId)
245
+
246
+ const blockSize = await parentDisk.mergeBlock(childDisk, blockId, this.#isResuming)
247
+ this.#state.mergedDataSize += blockSize
248
+
249
+ this.#state.currentBlock = Math.min(...merging) - 1
250
+ merging.delete(blockId)
251
+
252
+ this.#onProgress({ total: nBlocks, done: counter + 1 })
253
+ counter++
254
+ await this.#writeStateThrottled()
255
+ },
256
+ { concurrency: this.#mergeBlockConcurrency }
257
+ )
258
+
259
+ await this.#writeState()
260
+
261
+ this.#state.diskSize = childDisk.getSizeOnDisk()
262
+
263
+ this.#onProgress({ total: nBlocks, done: nBlocks })
264
+ }
265
+
266
+ /**
267
+ * @param {RemoteDisk} parentDisk
268
+ * @param {RemoteDisk} childDisk
269
+ */
270
+ async #step_cleanup(parentDisk, childDisk) {
271
+ assert.notEqual(this.#state, undefined)
272
+ this.#state.step = 'cleanup'
273
+ await this.#writeState()
274
+
275
+ const mergeTargetPath = childDisk.getPath()
276
+
277
+ // delete intermediate children if needed
278
+ if (this.#removeUnused) {
279
+ await childDisk.unlink()
280
+ }
281
+
282
+ try {
283
+ await parentDisk.rename(mergeTargetPath)
284
+ } catch (error) {
285
+ // @ts-ignore
286
+ if (error.code === 'ENOENT' && this.#isResuming) {
287
+ // @ts-ignore
288
+ this.#logInfo(`the parent disk was already renamed`, {
289
+ parent: parentDisk.getPath(),
290
+ mergeTarget: mergeTargetPath,
291
+ })
292
+ } else {
293
+ throw error
294
+ }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * @param {RemoteDisk} parentDisk
300
+ * @param {RemoteDisk} childDisk
301
+ * @param {boolean} cleanStateFile
302
+ *
303
+ * @returns {Promise<{mergedDataSize: number, finalDiskSize: number}>} result
304
+ */
305
+ async #cleanup(parentDisk, childDisk, cleanStateFile) {
306
+ const finalDiskSize = this.#state?.diskSize ?? 0
307
+ const mergedDataSize = this.#state?.mergedDataSize ?? 0
308
+ await parentDisk.close().catch(warn)
309
+ await childDisk.close().catch(warn)
310
+
311
+ if (cleanStateFile) {
312
+ await this.#handler.unlink(this.#statePath).catch(warn)
313
+ }
314
+
315
+ return { mergedDataSize, finalDiskSize }
316
+ }
317
+ }