@xen-orchestra/backups 0.67.1 → 0.68.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/ImportVmBackup.mjs +2 -0
- package/RemoteAdapter.mjs +1 -1
- package/_getOldEntries.mjs +153 -54
- package/_otherConfig.mjs +2 -34
- package/_runners/_vmRunners/FullXapi.mjs +9 -2
- package/_runners/_vmRunners/_AbstractXapi.mjs +137 -51
- package/_runners/_writers/FullRemoteWriter.mjs +1 -0
- package/_runners/_writers/IncrementalRemoteWriter.mjs +1 -0
- package/_runners/_writers/_AbstractWriter.mjs +0 -13
- package/_runners/_writers/_MixinRemoteWriter.mjs +14 -0
- package/formatVmBackups.mjs +1 -1
- package/package.json +7 -7
package/ImportVmBackup.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import pickBy from 'lodash/pickBy.js'
|
|
|
11
11
|
import { defer } from 'golike-defer'
|
|
12
12
|
import { NegativeDisk } from '@xen-orchestra/disk-transform'
|
|
13
13
|
import { openDiskChain } from './disks/openDiskChain.mjs'
|
|
14
|
+
import { resetVmOtherConfig } from './_otherConfig.mjs'
|
|
14
15
|
|
|
15
16
|
const { debug, info, warn } = createLogger('xo:backups:importVmBackup')
|
|
16
17
|
async function resolveUuid(xapi, cache, uuid, type) {
|
|
@@ -270,6 +271,7 @@ export class ImportVmBackup {
|
|
|
270
271
|
`${metadata.vm.name_label} (${formatFilenameDate(metadata.timestamp)})`
|
|
271
272
|
),
|
|
272
273
|
xapi.call('VM.set_name_description', vmRef, desc),
|
|
274
|
+
resetVmOtherConfig(xapi, vmRef),
|
|
273
275
|
])
|
|
274
276
|
|
|
275
277
|
return {
|
package/RemoteAdapter.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import groupBy from 'lodash/groupBy.js'
|
|
|
18
18
|
import pDefer from 'promise-toolbox/defer'
|
|
19
19
|
import pickBy from 'lodash/pickBy.js'
|
|
20
20
|
import reduce from 'lodash/reduce.js'
|
|
21
|
-
import tar from 'tar'
|
|
21
|
+
import * as tar from 'tar'
|
|
22
22
|
import zlib from 'zlib'
|
|
23
23
|
|
|
24
24
|
import { BACKUP_DIR } from './_getVmBackupDir.mjs'
|
package/_getOldEntries.mjs
CHANGED
|
@@ -1,5 +1,58 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
1
3
|
import moment from 'moment-timezone'
|
|
2
4
|
import assert from 'node:assert'
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {import('moment-timezone').Moment} Moment
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} LTRSettings
|
|
10
|
+
* @property {number} [firstHourOfTheDay] - First hour of the day (for daily retention)
|
|
11
|
+
* @property {number} [firstDayOfWeek] - First day of the week (for weekly retention)
|
|
12
|
+
* @property {number} [firstDayOfMonth] - First day of the month (for monthly retention)
|
|
13
|
+
* @property {number} [firstDayOfYear] - First day of the year (for yearly retention)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} LTRConfig
|
|
18
|
+
* @property {number} retention - Number of time buckets to retain
|
|
19
|
+
* @property {LTRSettings} [settings] - Optional settings for the retention period
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} LongTermRetention
|
|
24
|
+
* @property {LTRConfig} [daily] - Daily retention configuration
|
|
25
|
+
* @property {LTRConfig} [weekly] - Weekly retention configuration
|
|
26
|
+
* @property {LTRConfig} [monthly] - Monthly retention configuration
|
|
27
|
+
* @property {LTRConfig} [yearly] - Yearly retention configuration
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} Entry
|
|
32
|
+
* @property {number | undefined} timestamp - Unix timestamp of the entry
|
|
33
|
+
* @property {string} id
|
|
34
|
+
* @property {*} [key] - Any other properties
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {Object} DateBucket
|
|
39
|
+
* @property {number} remaining - Number of remaining buckets to fill
|
|
40
|
+
* @property {string|null} lastMatchingBucket - Last bucket key that was matched
|
|
41
|
+
* @property {(date: Moment) => string} formatter - Function to format date into bucket key
|
|
42
|
+
* @property {Object.<string, Entry>} entries - Map of bucket keys to entries
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {Object} LTRDefinition
|
|
47
|
+
* @property {(options: {firstHourOfTheDay?: number, firstDayOfWeek?: number, firstDayOfMonth?: number, firstDayOfYear?: number, dateCreator: (date: Moment) => Moment}) => (date: Moment) => string} makeDateFormatter
|
|
48
|
+
* @property {string} [ancestor] - Name of the parent retention period
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Creates a function that converts dates to moment objects in a specific timezone
|
|
53
|
+
* @param {string|undefined} timezone - IANA timezone string (e.g., 'America/New_York')
|
|
54
|
+
* @returns {(date: Moment | number) => Moment} Function that creates timezoned moment objects
|
|
55
|
+
*/
|
|
3
56
|
|
|
4
57
|
function instantiateTimezonedDateCreator(timezone) {
|
|
5
58
|
return date => {
|
|
@@ -9,9 +62,12 @@ function instantiateTimezonedDateCreator(timezone) {
|
|
|
9
62
|
}
|
|
10
63
|
}
|
|
11
64
|
|
|
65
|
+
/**
|
|
66
|
+
* @type {Object.<string, LTRDefinition>}
|
|
67
|
+
*/
|
|
12
68
|
const LTR_DEFINITIONS = {
|
|
13
69
|
daily: {
|
|
14
|
-
makeDateFormatter: ({ firstHourOfTheDay = 0, dateCreator }
|
|
70
|
+
makeDateFormatter: ({ firstHourOfTheDay = 0, dateCreator }) => {
|
|
15
71
|
return date => {
|
|
16
72
|
const copy = dateCreator(date)
|
|
17
73
|
copy.hour(copy.hour() - firstHourOfTheDay)
|
|
@@ -20,11 +76,11 @@ const LTR_DEFINITIONS = {
|
|
|
20
76
|
},
|
|
21
77
|
},
|
|
22
78
|
weekly: {
|
|
23
|
-
makeDateFormatter: ({ firstDayOfWeek = 0 /* relative to timezone week start */, dateCreator }
|
|
79
|
+
makeDateFormatter: ({ firstDayOfWeek = 0 /* relative to timezone week start */, dateCreator }) => {
|
|
24
80
|
return date => {
|
|
25
81
|
const copy = dateCreator(date)
|
|
26
82
|
|
|
27
|
-
copy.date(
|
|
83
|
+
copy.date(copy.date() - firstDayOfWeek)
|
|
28
84
|
// warning, the year in term of week may different from YYYY
|
|
29
85
|
// since the computation of the first week of a year is timezone dependent
|
|
30
86
|
return copy.format('gggg-ww')
|
|
@@ -33,7 +89,7 @@ const LTR_DEFINITIONS = {
|
|
|
33
89
|
ancestor: 'daily',
|
|
34
90
|
},
|
|
35
91
|
monthly: {
|
|
36
|
-
makeDateFormatter: ({ firstDayOfMonth = 0, dateCreator }
|
|
92
|
+
makeDateFormatter: ({ firstDayOfMonth = 0, dateCreator }) => {
|
|
37
93
|
return date => {
|
|
38
94
|
const copy = dateCreator(date)
|
|
39
95
|
copy.date(copy.date() - firstDayOfMonth)
|
|
@@ -43,7 +99,7 @@ const LTR_DEFINITIONS = {
|
|
|
43
99
|
ancestor: 'weekly',
|
|
44
100
|
},
|
|
45
101
|
yearly: {
|
|
46
|
-
makeDateFormatter: ({ firstDayOfYear = 0, dateCreator }
|
|
102
|
+
makeDateFormatter: ({ firstDayOfYear = 0, dateCreator }) => {
|
|
47
103
|
return date => {
|
|
48
104
|
const copy = dateCreator(date)
|
|
49
105
|
copy.date(copy.date() - firstDayOfYear)
|
|
@@ -53,27 +109,23 @@ const LTR_DEFINITIONS = {
|
|
|
53
109
|
ancestor: 'monthly',
|
|
54
110
|
},
|
|
55
111
|
}
|
|
56
|
-
|
|
57
|
-
// returns all entries but the last retention-th
|
|
58
112
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* @
|
|
113
|
+
* Groups entries into date buckets based on long-term retention configuration
|
|
114
|
+
* @template {Entry} T
|
|
115
|
+
* @param {Array<T>} entries - Array of entries sorted in descending order by timestamp
|
|
116
|
+
* @param {LongTermRetention} longTermRetention - Configuration for retention periods
|
|
117
|
+
* @param {string | undefined} timezone - IANA timezone string
|
|
118
|
+
* @returns {Object.<string, DateBucket>} Map of retention period names to their date buckets
|
|
64
119
|
*/
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
71
|
-
assert.strictEqual(
|
|
72
|
-
minRetentionCount >= 0,
|
|
73
|
-
true,
|
|
74
|
-
`minRetentionCount must be a positive number, got ${JSON.stringify(minRetentionCount)}`
|
|
75
|
-
)
|
|
120
|
+
|
|
121
|
+
export function getLtrEntries(entries, longTermRetention, timezone) {
|
|
122
|
+
/**
|
|
123
|
+
* @type {Object.<string, DateBucket>}
|
|
124
|
+
*/
|
|
76
125
|
const dateBuckets = {}
|
|
126
|
+
if (!longTermRetention || Object.keys(longTermRetention).length === 0) {
|
|
127
|
+
return {}
|
|
128
|
+
}
|
|
77
129
|
const dateCreator = instantiateTimezonedDateCreator(timezone)
|
|
78
130
|
// only check buckets that have a retention set
|
|
79
131
|
for (const [duration, { retention, settings }] of Object.entries(longTermRetention)) {
|
|
@@ -81,7 +133,7 @@ export function getOldEntries(minRetentionCount, entries, { longTermRetention =
|
|
|
81
133
|
assert.notStrictEqual(
|
|
82
134
|
timezone,
|
|
83
135
|
undefined,
|
|
84
|
-
`timezone must defined for ltr, got ${JSON.stringify({
|
|
136
|
+
`timezone must defined for ltr, got ${JSON.stringify({ longTermRetention, timezone })}`
|
|
85
137
|
)
|
|
86
138
|
assert.strictEqual(
|
|
87
139
|
typeof retention,
|
|
@@ -101,45 +153,92 @@ export function getOldEntries(minRetentionCount, entries, { longTermRetention =
|
|
|
101
153
|
}
|
|
102
154
|
}
|
|
103
155
|
const nb = entries.length
|
|
104
|
-
const minDurationEntries = []
|
|
105
156
|
let previousTimestamp = -1
|
|
106
157
|
for (let i = nb - 1; i >= 0; i--) {
|
|
107
158
|
const entry = entries[i]
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
assert.
|
|
111
|
-
|
|
112
|
-
|
|
159
|
+
// force it so the compiler is sure we have a timestamp
|
|
160
|
+
if (entry.timestamp === undefined) {
|
|
161
|
+
assert.notStrictEqual(
|
|
162
|
+
entry.timestamp,
|
|
163
|
+
undefined,
|
|
164
|
+
`Can't compute long term retention if entries don't have a timestamp`
|
|
113
165
|
)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
// we go through the entries from the last (most recent) to the first (oldest)
|
|
169
|
+
assert.ok(
|
|
170
|
+
previousTimestamp === -1 || entry.timestamp < previousTimestamp,
|
|
171
|
+
`entries must be sorted in desc order ${new Date(entry.timestamp)} , previous : > ${new Date(previousTimestamp)} `
|
|
172
|
+
)
|
|
173
|
+
previousTimestamp = entry.timestamp
|
|
174
|
+
const entryDate = dateCreator(entry.timestamp)
|
|
175
|
+
for (const [duration, { remaining, lastMatchingBucket, formatter }] of Object.entries(dateBuckets)) {
|
|
176
|
+
const bucket = formatter(entryDate)
|
|
177
|
+
if (lastMatchingBucket !== bucket) {
|
|
178
|
+
if (remaining === 0) {
|
|
179
|
+
continue
|
|
124
180
|
}
|
|
125
|
-
dateBuckets[duration].
|
|
181
|
+
dateBuckets[duration].lastMatchingBucket = bucket
|
|
182
|
+
dateBuckets[duration].remaining -= 1
|
|
126
183
|
}
|
|
127
|
-
|
|
128
|
-
// replicated VM entries or snapshot retention don't have a timestamp
|
|
129
|
-
// but also can't have LTR
|
|
130
|
-
assert.deepStrictEqual(
|
|
131
|
-
longTermRetention,
|
|
132
|
-
{},
|
|
133
|
-
"Can't compute long term retention if entries don't have a timestamp"
|
|
134
|
-
)
|
|
184
|
+
dateBuckets[duration].entries[bucket] = entry
|
|
135
185
|
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return dateBuckets
|
|
189
|
+
}
|
|
136
190
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
191
|
+
/**
|
|
192
|
+
* return the status of an entry
|
|
193
|
+
* @param {Array<Entry>} entries
|
|
194
|
+
* @param {Entry} entry
|
|
195
|
+
* @param {LongTermRetention} longTermRetention - Configuration for retention periods
|
|
196
|
+
* @param {string | undefined} timezone - IANA timezone string
|
|
197
|
+
* @returns {Array<{duration: string, dateBucket:string}>}
|
|
198
|
+
*/
|
|
199
|
+
export function getEntryStatus(entries, entry, longTermRetention, timezone) {
|
|
200
|
+
const dateBuckets = getLtrEntries(entries, longTermRetention, timezone)
|
|
201
|
+
const entryBuckets = []
|
|
202
|
+
for (const [duration, { entries }] of Object.entries(dateBuckets)) {
|
|
203
|
+
for (const [dateBucket, bucketEntry] of Object.entries(entries)) {
|
|
204
|
+
if (entry.id === bucketEntry.id) {
|
|
205
|
+
entryBuckets.push({ duration, dateBucket })
|
|
206
|
+
}
|
|
140
207
|
}
|
|
141
208
|
}
|
|
142
|
-
|
|
209
|
+
return entryBuckets
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* return the entries too old to be kept
|
|
214
|
+
* if multiple entries are in the same time bucket: keep only the oldest one
|
|
215
|
+
* if an entry is valid in any of the bucket OR the minRetentionCount: keep it
|
|
216
|
+
* if a bucket is completely empty: it does not count as one, thus it may extend the retention
|
|
217
|
+
* if an entry is within the most recents minRetentionCount entries : keep it
|
|
218
|
+
*
|
|
219
|
+
* @param {number} minRetentionCount - Minimum number of most recent entries to always keep
|
|
220
|
+
* @param {Array<Entry>} entries - Array of entries sorted in descending order by timestamp
|
|
221
|
+
* @param {Object} options - Additional options
|
|
222
|
+
* @param {LongTermRetention} [options.longTermRetention={}] - Configuration for retention periods
|
|
223
|
+
* @param {string | undefined} [options.timezone] - IANA timezone string
|
|
224
|
+
* @returns {Array<Entry>} Array of entries that can be removed
|
|
225
|
+
*/
|
|
226
|
+
export function getOldEntries(minRetentionCount, entries, { longTermRetention = {}, timezone } = {}) {
|
|
227
|
+
assert.strictEqual(
|
|
228
|
+
typeof minRetentionCount,
|
|
229
|
+
'number',
|
|
230
|
+
`minRetentionCount must be a number, got ${JSON.stringify(minRetentionCount)}`
|
|
231
|
+
)
|
|
232
|
+
assert.ok(
|
|
233
|
+
minRetentionCount >= 0,
|
|
234
|
+
`minRetentionCount must be a positive number, got ${JSON.stringify(minRetentionCount)}`
|
|
235
|
+
)
|
|
236
|
+
const dateBuckets = getLtrEntries(entries, longTermRetention, timezone)
|
|
237
|
+
|
|
238
|
+
const kept = new Set(entries.filter((_, index) => index >= entries.length - minRetentionCount))
|
|
239
|
+
/**
|
|
240
|
+
* @type {Set<Entry>}
|
|
241
|
+
*/
|
|
143
242
|
for (const { entries } of Object.values(dateBuckets)) {
|
|
144
243
|
for (const entry of Object.values(entries)) {
|
|
145
244
|
kept.add(entry)
|
|
@@ -147,7 +246,7 @@ export function getOldEntries(minRetentionCount, entries, { longTermRetention =
|
|
|
147
246
|
}
|
|
148
247
|
|
|
149
248
|
// ensure order is the same as the source
|
|
150
|
-
const oldEntries = entries.filter(entry => {
|
|
249
|
+
const oldEntries = entries.filter((entry, index) => {
|
|
151
250
|
return !kept.has(entry)
|
|
152
251
|
})
|
|
153
252
|
|
package/_otherConfig.mjs
CHANGED
|
@@ -70,7 +70,8 @@ export async function getVmDeltaChainLength(xapi, vmRef) {
|
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
72
|
*
|
|
73
|
-
* Reset the other_config field of a VM and its VDIs
|
|
73
|
+
* Reset the other_config field related to backups of a VM and its VDIs
|
|
74
|
+
*
|
|
74
75
|
*
|
|
75
76
|
* @param {Xapi} xapi
|
|
76
77
|
* @param {String} vmRef
|
|
@@ -90,39 +91,6 @@ export function resetVmOtherConfig(xapi, vmRef) {
|
|
|
90
91
|
})
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
/**
|
|
94
|
-
*
|
|
95
|
-
* used to ensure compatibility with the previous snapshots that were having the config stored only into VM
|
|
96
|
-
*
|
|
97
|
-
* @param {Xapi} xapi
|
|
98
|
-
* @param {String} vmRef
|
|
99
|
-
* @returns {Promise}
|
|
100
|
-
*/
|
|
101
|
-
export async function populateVdisOtherConfig(xapi, vmRef) {
|
|
102
|
-
const otherConfig = await xapi.getField('VM', vmRef, 'other_config')
|
|
103
|
-
const {
|
|
104
|
-
[DATETIME]: datetime,
|
|
105
|
-
[DELTA_CHAIN_LENGTH]: chainLength,
|
|
106
|
-
[EXPORTED_SUCCESSFULLY]: successfully,
|
|
107
|
-
[JOB_ID]: jobId,
|
|
108
|
-
[REPLICATED_TO_SR_UUID]: replicatedTo,
|
|
109
|
-
[SCHEDULE_ID]: scheduleId,
|
|
110
|
-
[VM_UUID]: vmUuid,
|
|
111
|
-
} = otherConfig
|
|
112
|
-
|
|
113
|
-
return applyToVmAndVdis(xapi, vmRef, (type, ref) =>
|
|
114
|
-
xapi.setFieldEntries(type, ref, 'other_config', {
|
|
115
|
-
[DATETIME]: datetime,
|
|
116
|
-
[DELTA_CHAIN_LENGTH]: chainLength,
|
|
117
|
-
[EXPORTED_SUCCESSFULLY]: successfully,
|
|
118
|
-
[JOB_ID]: jobId,
|
|
119
|
-
[REPLICATED_TO_SR_UUID]: replicatedTo,
|
|
120
|
-
[SCHEDULE_ID]: scheduleId,
|
|
121
|
-
[VM_UUID]: vmUuid,
|
|
122
|
-
})
|
|
123
|
-
)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
94
|
/**
|
|
127
95
|
*
|
|
128
96
|
* set the other_config key related to a backup of a VM and its associated VDIs
|
|
@@ -64,9 +64,16 @@ export const FullXapi = class FullXapiVmBackupRunner extends AbstractXapi {
|
|
|
64
64
|
const vdi = await this._xapi.getRecord('VDI', vdiRef)
|
|
65
65
|
|
|
66
66
|
// the size a of fully allocated vdi will be virtual_size exactly, it's a gross over evaluation
|
|
67
|
-
// of the real stream size in general, since a disk is
|
|
67
|
+
// of the real stream size in general, since a disk is rarely completely full
|
|
68
68
|
// vdi.physical_size seems to underevaluate a lot the real disk usage of a VDI, as of 2023-10-30
|
|
69
|
-
|
|
69
|
+
|
|
70
|
+
// xva files are tar archive with data cut in 1MB blocks
|
|
71
|
+
// each block is signed with a hash (in xxhash format)
|
|
72
|
+
// each of these blocks + hash add one tar header entry in the file
|
|
73
|
+
const XVA_BLOCK_SIZE = 1024 * 1024
|
|
74
|
+
const nbBlocks = Math.ceil(vdi.virtual_size / XVA_BLOCK_SIZE)
|
|
75
|
+
const headersSize = nbBlocks * (512 /* file header */ + 512 /* xxhash header */ + 512) /* xhxhash size */
|
|
76
|
+
maxStreamLength += XVA_BLOCK_SIZE * nbBlocks + headersSize
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
const sizeContainer = watchStreamSize(stream)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert'
|
|
2
2
|
import groupBy from 'lodash/groupBy.js'
|
|
3
|
+
import { createLogger } from '@xen-orchestra/log'
|
|
3
4
|
import ignoreErrors from 'promise-toolbox/ignoreErrors'
|
|
4
5
|
import { asyncMap } from '@xen-orchestra/async-map'
|
|
5
6
|
import { decorateMethodsWith } from '@vates/decorate-with'
|
|
@@ -8,14 +9,9 @@ import { defer } from 'golike-defer'
|
|
|
8
9
|
import { getOldEntries } from '../../_getOldEntries.mjs'
|
|
9
10
|
import { Task } from '../../Task.mjs'
|
|
10
11
|
import { Abstract } from './_Abstract.mjs'
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
SCHEDULE_ID,
|
|
15
|
-
populateVdisOtherConfig,
|
|
16
|
-
resetVmOtherConfig,
|
|
17
|
-
setVmOtherConfig,
|
|
18
|
-
} from '../../_otherConfig.mjs'
|
|
12
|
+
import { DATETIME, JOB_ID, SCHEDULE_ID, VM_UUID, resetVmOtherConfig, setVmOtherConfig } from '../../_otherConfig.mjs'
|
|
13
|
+
|
|
14
|
+
const { warn, info } = createLogger('xo:backups:AbstractXapi')
|
|
19
15
|
|
|
20
16
|
export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
21
17
|
constructor({
|
|
@@ -179,43 +175,113 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
179
175
|
}
|
|
180
176
|
}
|
|
181
177
|
|
|
178
|
+
// handle snapshot by VDI since snapshot of cbtDestroySnapshotData jobs
|
|
179
|
+
// aren't attached to any VM
|
|
180
|
+
|
|
181
|
+
// look at all the vdi snapshots ( snapshot_of not empty )
|
|
182
|
+
// with the same vm_uuid and job_uuid
|
|
183
|
+
// for cbt_metadata list them unconditionnaly to remove older one
|
|
184
|
+
// for other: only list them if they are attached to a VM snapshot
|
|
185
|
+
// and if this vm snapshot is also part of the backup
|
|
186
|
+
// ensure they are attached to only one vm snapshot
|
|
187
|
+
// ensure any VM-snapshot harvested by this has all its disk harvested (no mix of vdi snapshot from this job and not)
|
|
188
|
+
|
|
182
189
|
async _fetchJobSnapshots() {
|
|
183
190
|
const jobId = this._jobId
|
|
184
|
-
const vmRef = this._vm.$ref
|
|
185
191
|
const xapi = this._xapi
|
|
186
192
|
|
|
187
|
-
|
|
188
|
-
// update vdi data to ensure the vdi are correctly fetched in _jobSnapshotVdis
|
|
189
|
-
// remove by then end of 2024
|
|
190
|
-
const vmSnapshotsRef = await xapi.getField('VM', vmRef, 'snapshots')
|
|
191
|
-
const vmSnapshotsOtherConfig = await asyncMap(vmSnapshotsRef, ref => xapi.getField('VM', ref, 'other_config'))
|
|
193
|
+
const vdiCandidates = {}
|
|
192
194
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
195
|
+
Object.values(xapi.objects.indexes.type.VDI)
|
|
196
|
+
.filter(_ => !!_) // filter nullish
|
|
197
|
+
.filter(({ other_config, $snapshot_of }) => {
|
|
198
|
+
return $snapshot_of !== undefined && other_config[JOB_ID] === jobId && other_config[VM_UUID] === this._vm.uuid
|
|
199
|
+
})
|
|
200
|
+
.forEach(vdi => {
|
|
201
|
+
vdiCandidates[vdi.uuid] = vdi
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// check that user snapshots are clean
|
|
205
|
+
|
|
206
|
+
for (const vdi of Object.values(vdiCandidates)) {
|
|
207
|
+
// cbt metadata are always considered linked to a backup job
|
|
208
|
+
// if they have the right other_config
|
|
209
|
+
if (vdi.type === 'cbt_metadata') {
|
|
210
|
+
continue
|
|
197
211
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// and only if the job is still using purge snapshot data or if the disk
|
|
210
|
-
// is not a cbt metadata disk ( expect a type: user for normal disks)
|
|
211
|
-
if (
|
|
212
|
-
snapshot.other_config[JOB_ID] === jobId &&
|
|
213
|
-
(this._settings.cbtDestroySnapshotData || snapshot.type !== 'cbt_metadata')
|
|
214
|
-
) {
|
|
215
|
-
this._jobSnapshotVdis.push(snapshot)
|
|
216
|
-
}
|
|
212
|
+
const vbds = vdi.$VBDs
|
|
213
|
+
.filter(({ $VM }) => !!$VM) // filter empty VMs
|
|
214
|
+
.filter(({ $VM }) => $VM.is_control_domain === false) // do not handle control domain
|
|
215
|
+
if (vbds.length === 0) {
|
|
216
|
+
// orphan vdi snapshot
|
|
217
|
+
info(
|
|
218
|
+
`disk snapshot ${vdi.name_label} is orphan or attached only to control domain,
|
|
219
|
+
it will be removed at the end of a successful backup run`,
|
|
220
|
+
{ vdi, attachedto: vdi.$VBDs.map(vbd => vbd?.$VM) }
|
|
221
|
+
)
|
|
222
|
+
continue
|
|
217
223
|
}
|
|
224
|
+
const userVms = vbds.map(({ $VM }) => $VM)
|
|
225
|
+
if (vbds.length > 1) {
|
|
226
|
+
warn(
|
|
227
|
+
`vdi ${vdi.name_label} (${vdi.uuid}) is linked to multipe vms : ${userVms.map(({ name_label, uuid }) => `${name_label} ${uuid}`).join(', ')}.
|
|
228
|
+
This disk snapshot will be excluded from the backup cleaning`,
|
|
229
|
+
{ vdi, userVms }
|
|
230
|
+
)
|
|
231
|
+
delete vdiCandidates[vdi.uuid]
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const vm = vbds[0].$VM
|
|
236
|
+
|
|
237
|
+
// xapi has some issue regarding vdi snapshot attached to non snapshot VM
|
|
238
|
+
// it can also be some user action forcibely linking a vdi snapshot to a vm
|
|
239
|
+
// => we exclude these from the backup processing
|
|
240
|
+
if (vm.$snapshot_of === undefined) {
|
|
241
|
+
warn(
|
|
242
|
+
`vdi ${vdi.name_label} (${vdi.uuid}) is a snapshot linked to a non snapshot vm ${vm.name_label} ${vm.uuid}.
|
|
243
|
+
This disk snapshot will be excluded from the backup cleaning`,
|
|
244
|
+
{ vdi, vm }
|
|
245
|
+
)
|
|
246
|
+
delete vdiCandidates[vdi.uuid]
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// vdi is attached only to a snapshot that is not a backup snapshot
|
|
251
|
+
// we don't check scheduleId since we are looking for all the snapshot of this job
|
|
252
|
+
// => excludes from the list to be cleared
|
|
253
|
+
if (
|
|
254
|
+
vm.other_config[DATETIME] !== vdi.other_config[DATETIME] ||
|
|
255
|
+
vm.other_config[JOB_ID] !== vdi.other_config[JOB_ID] ||
|
|
256
|
+
vm.other_config[VM_UUID] !== vdi.other_config[VM_UUID]
|
|
257
|
+
) {
|
|
258
|
+
warn(
|
|
259
|
+
`vdi ${vdi.name_label} (${vdi.uuid}) is a snapshot linked to a snapshot vm ${vm.name_label} ${vm.uuid} out of this backup job scope.
|
|
260
|
+
This disk snapshot will be excluded from the backup cleaning`,
|
|
261
|
+
{ vdi, vm }
|
|
262
|
+
)
|
|
263
|
+
delete vdiCandidates[vdi.uuid]
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// check if all the disks of these VM snapshot have been harvested
|
|
268
|
+
// if not => remove it from the list to ensure we won't half destroy VM later
|
|
269
|
+
vm.$VBDs
|
|
270
|
+
.filter(({ $VDI }) => !!$VDI) // filter missing keys
|
|
271
|
+
.filter(({ $VDI }) => $VDI && vdiCandidates[$VDI.uuid] === undefined)
|
|
272
|
+
.forEach(({ $VDI: outOfSnapshotsVdi, ...other }) => {
|
|
273
|
+
warn(
|
|
274
|
+
`vdi ${vdi.name_label} ${vdi.uuid} is recognized as a snapshot of the backup job,
|
|
275
|
+
linked to vm ${vm.name_label} ${vm.uuid} but vdi ${outOfSnapshotsVdi.name_label} ${outOfSnapshotsVdi.uuid}
|
|
276
|
+
is not linked to the job. This disk snapshot will be excluded from the backup cleaning`,
|
|
277
|
+
{ vdi, vm, vbds, outOfSnapshotsVdi }
|
|
278
|
+
)
|
|
279
|
+
// this will be called multiple time but it is not really an issue
|
|
280
|
+
delete vdiCandidates[vdi.uuid]
|
|
281
|
+
})
|
|
218
282
|
}
|
|
283
|
+
|
|
284
|
+
this._jobSnapshotVdis = Object.values(vdiCandidates)
|
|
219
285
|
}
|
|
220
286
|
|
|
221
287
|
async _removeUnusedSnapshots() {
|
|
@@ -255,28 +321,47 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
255
321
|
return
|
|
256
322
|
}
|
|
257
323
|
const vdis = snapshotPerDatetime[datetime]
|
|
258
|
-
let
|
|
324
|
+
let vm
|
|
259
325
|
// if there is an attached VM => destroy the VM (Non CBT backups)
|
|
260
326
|
for (const vdi of vdis) {
|
|
261
327
|
const vbds = vdi.$VBDs.filter(({ $VM }) => $VM.is_control_domain === false)
|
|
262
328
|
if (vbds.length > 0) {
|
|
263
329
|
// only one VM linked to this vdi
|
|
264
330
|
// this will throw error for VDI still attached to control domain
|
|
331
|
+
// since we won't be able to remove an attached VDI
|
|
265
332
|
assert.strictEqual(vbds.length, 1, 'VDI must be free or attached to exactly one VM')
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
)
|
|
275
|
-
|
|
333
|
+
const vdiVm = vbds[0].$VM
|
|
334
|
+
if (vdiVm.$snapshot_of === undefined) {
|
|
335
|
+
// don't delete a VM (especially a control domain)
|
|
336
|
+
warn(
|
|
337
|
+
`VM ${vdiVm.uuid} (${vdiVm.name_label}) linked to VDI ${vdi.uuid} (${vdi.name_label}) should be a snapshot`
|
|
338
|
+
)
|
|
339
|
+
throw new Error(`VM must be a snapshot`)
|
|
340
|
+
}
|
|
341
|
+
if (vm !== undefined && vm.$ref !== vdiVm.$ref) {
|
|
342
|
+
// this VDI is attached to another VM than the other vdi of
|
|
343
|
+
// this batch
|
|
344
|
+
// in doubt, do not delete anything
|
|
345
|
+
warn("_removeUnusedSnapshots don't handle vdi related to multiple VMs ", {
|
|
346
|
+
vm1: {
|
|
347
|
+
label: vm.name_label,
|
|
348
|
+
id: vm.$id,
|
|
349
|
+
},
|
|
350
|
+
vm2: {
|
|
351
|
+
label: vdiVm.name_label,
|
|
352
|
+
id: vdiVm.$id,
|
|
353
|
+
},
|
|
354
|
+
vdis: vdis.map(({ name_label, $id }) => ({ name_label, $id })),
|
|
355
|
+
})
|
|
356
|
+
throw new Error(
|
|
357
|
+
`_removeUnusedSnapshots don't handle vdi related to multiple VMs ${vm.name_label} and ${vdiVm.name_label}`
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
vm = vdiVm
|
|
276
361
|
}
|
|
277
362
|
}
|
|
278
|
-
if (
|
|
279
|
-
return xapi.VM_destroy(
|
|
363
|
+
if (vm?.$ref !== undefined) {
|
|
364
|
+
return xapi.VM_destroy(vm.$ref)
|
|
280
365
|
} else {
|
|
281
366
|
return asyncMap(
|
|
282
367
|
vdis.map(async ({ $ref }) => {
|
|
@@ -293,8 +378,9 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
293
378
|
// going back to a previous version of XO not supporting CBT will create a full backup
|
|
294
379
|
// this will only do something after snapshot and transfer
|
|
295
380
|
if (
|
|
381
|
+
this._exportedVm !== undefined &&
|
|
296
382
|
// don't modify the VM
|
|
297
|
-
this._exportedVm
|
|
383
|
+
this._exportedVm.$snapshot_of !== undefined &&
|
|
298
384
|
// user don't want to keep the snapshot data
|
|
299
385
|
this._settings.snapshotRetention === 0 &&
|
|
300
386
|
// preferNbd is not a guarantee that the backup used NBD, depending on the network configuration,
|
|
@@ -73,6 +73,7 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
|
|
|
73
73
|
return { size: sizeContainer.size }
|
|
74
74
|
})
|
|
75
75
|
metadata.size = sizeContainer.size
|
|
76
|
+
metadata.tags = await this.getLongTermRetentionTags(metadata)
|
|
76
77
|
this._metadataFileName = await adapter.writeVmBackupMetadata(vm.uuid, metadata)
|
|
77
78
|
|
|
78
79
|
if (!deleteFirst) {
|
|
@@ -244,6 +244,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
|
|
|
244
244
|
|
|
245
245
|
return { size }
|
|
246
246
|
})
|
|
247
|
+
metadataContent.tags = await this.getLongTermRetentionTags(metadataContent)
|
|
247
248
|
metadataContent.size = size // @todo return exactly the size written by this writer
|
|
248
249
|
this._metadataFileName = await adapter.writeVmBackupMetadata(vm.uuid, metadataContent)
|
|
249
250
|
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { formatFilenameDate } from '../../_filenameDate.mjs'
|
|
2
|
-
import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
|
|
3
|
-
|
|
4
1
|
export class AbstractWriter {
|
|
5
2
|
constructor({ config, healthCheckSr, job, vmUuid, scheduleId, settings }) {
|
|
6
3
|
this._config = config
|
|
@@ -16,14 +13,4 @@ export class AbstractWriter {
|
|
|
16
13
|
afterBackup() {}
|
|
17
14
|
|
|
18
15
|
healthCheck(sr) {}
|
|
19
|
-
|
|
20
|
-
_isAlreadyTransferred(timestamp) {
|
|
21
|
-
const vmUuid = this._vmUuid
|
|
22
|
-
const adapter = this._adapter
|
|
23
|
-
const backupDir = getVmBackupDir(vmUuid)
|
|
24
|
-
try {
|
|
25
|
-
const actualMetadata = JSON.parse(adapter._handler.readFile(`${backupDir}/${formatFilenameDate(timestamp)}.json`))
|
|
26
|
-
return actualMetadata
|
|
27
|
-
} catch (error) {}
|
|
28
|
-
}
|
|
29
16
|
}
|
|
@@ -9,6 +9,7 @@ import { ImportVmBackup } from '../../ImportVmBackup.mjs'
|
|
|
9
9
|
import { Task } from '../../Task.mjs'
|
|
10
10
|
import * as MergeWorker from '../../merge-worker/index.mjs'
|
|
11
11
|
import ms from 'ms'
|
|
12
|
+
import { getEntryStatus } from '../../_getOldEntries.mjs'
|
|
12
13
|
|
|
13
14
|
const { info, warn } = createLogger('xo:backups:MixinBackupWriter')
|
|
14
15
|
|
|
@@ -47,6 +48,19 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
async getLongTermRetentionTags(currentEntry) {
|
|
52
|
+
const settings = this._settings
|
|
53
|
+
const scheduleId = this._scheduleId
|
|
54
|
+
const vmUuid = this._vmUuid
|
|
55
|
+
const adapter = this._adapter
|
|
56
|
+
|
|
57
|
+
const entries = await adapter.listVmBackups(vmUuid, _ => _.scheduleId === scheduleId)
|
|
58
|
+
entries.push(currentEntry)
|
|
59
|
+
const buckets = getEntryStatus(entries, currentEntry, settings.longTermRetention, settings.timezone)
|
|
60
|
+
|
|
61
|
+
return buckets.map(({ duration, dateBucket }) => `${duration}=${dateBucket}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
async beforeBackup() {
|
|
51
65
|
const { handler } = this._adapter
|
|
52
66
|
const vmBackupDir = this._vmBackupDir
|
package/formatVmBackups.mjs
CHANGED
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.68.0",
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=14.18"
|
|
14
14
|
},
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
"@vates/nbd-client": "^3.2.2",
|
|
30
30
|
"@vates/parse-duration": "^0.1.1",
|
|
31
31
|
"@xen-orchestra/async-map": "^0.1.2",
|
|
32
|
-
"@xen-orchestra/disk-transform": "^1.2.
|
|
32
|
+
"@xen-orchestra/disk-transform": "^1.2.1",
|
|
33
33
|
"@xen-orchestra/fs": "^4.6.5",
|
|
34
34
|
"@xen-orchestra/log": "^0.7.1",
|
|
35
|
-
"@xen-orchestra/qcow2": "^1.1.
|
|
35
|
+
"@xen-orchestra/qcow2": "^1.1.2",
|
|
36
36
|
"@xen-orchestra/template": "^0.1.0",
|
|
37
37
|
"app-conf": "^3.0.0",
|
|
38
38
|
"compare-versions": "^6.0.0",
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
"parse-pairs": "^2.0.0",
|
|
49
49
|
"promise-toolbox": "^0.21.0",
|
|
50
50
|
"proper-lockfile": "^4.1.2",
|
|
51
|
-
"tar": "^
|
|
51
|
+
"tar": "^7.5.3",
|
|
52
52
|
"uuid": "^9.0.0",
|
|
53
53
|
"value-matcher": "^0.2.0",
|
|
54
|
-
"vhd-lib": "^4.14.
|
|
55
|
-
"xen-api": "^4.7.
|
|
54
|
+
"vhd-lib": "^4.14.7",
|
|
55
|
+
"xen-api": "^4.7.6",
|
|
56
56
|
"yazl": "^2.5.1"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"tmp": "^0.2.1"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
|
-
"@xen-orchestra/xapi": "^8.6.
|
|
65
|
+
"@xen-orchestra/xapi": "^8.6.4"
|
|
66
66
|
},
|
|
67
67
|
"license": "AGPL-3.0-or-later",
|
|
68
68
|
"author": {
|