@xen-orchestra/backups 0.67.2 → 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/RemoteAdapter.mjs +1 -1
- package/_getOldEntries.mjs +153 -54
- package/_otherConfig.mjs +0 -33
- package/_runners/_vmRunners/FullXapi.mjs +9 -2
- package/_runners/_vmRunners/_AbstractXapi.mjs +104 -40
- 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 +5 -5
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
|
@@ -91,39 +91,6 @@ export function resetVmOtherConfig(xapi, vmRef) {
|
|
|
91
91
|
})
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
/**
|
|
95
|
-
*
|
|
96
|
-
* used to ensure compatibility with the previous snapshots that were having the config stored only into VM
|
|
97
|
-
*
|
|
98
|
-
* @param {Xapi} xapi
|
|
99
|
-
* @param {String} vmRef
|
|
100
|
-
* @returns {Promise}
|
|
101
|
-
*/
|
|
102
|
-
export async function populateVdisOtherConfig(xapi, vmRef) {
|
|
103
|
-
const otherConfig = await xapi.getField('VM', vmRef, 'other_config')
|
|
104
|
-
const {
|
|
105
|
-
[DATETIME]: datetime,
|
|
106
|
-
[DELTA_CHAIN_LENGTH]: chainLength,
|
|
107
|
-
[EXPORTED_SUCCESSFULLY]: successfully,
|
|
108
|
-
[JOB_ID]: jobId,
|
|
109
|
-
[REPLICATED_TO_SR_UUID]: replicatedTo,
|
|
110
|
-
[SCHEDULE_ID]: scheduleId,
|
|
111
|
-
[VM_UUID]: vmUuid,
|
|
112
|
-
} = otherConfig
|
|
113
|
-
|
|
114
|
-
return applyToVmAndVdis(xapi, vmRef, (type, ref) =>
|
|
115
|
-
xapi.setFieldEntries(type, ref, 'other_config', {
|
|
116
|
-
[DATETIME]: datetime,
|
|
117
|
-
[DELTA_CHAIN_LENGTH]: chainLength,
|
|
118
|
-
[EXPORTED_SUCCESSFULLY]: successfully,
|
|
119
|
-
[JOB_ID]: jobId,
|
|
120
|
-
[REPLICATED_TO_SR_UUID]: replicatedTo,
|
|
121
|
-
[SCHEDULE_ID]: scheduleId,
|
|
122
|
-
[VM_UUID]: vmUuid,
|
|
123
|
-
})
|
|
124
|
-
)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
94
|
/**
|
|
128
95
|
*
|
|
129
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)
|
|
@@ -9,16 +9,9 @@ import { defer } from 'golike-defer'
|
|
|
9
9
|
import { getOldEntries } from '../../_getOldEntries.mjs'
|
|
10
10
|
import { Task } from '../../Task.mjs'
|
|
11
11
|
import { Abstract } from './_Abstract.mjs'
|
|
12
|
-
import {
|
|
13
|
-
DATETIME,
|
|
14
|
-
JOB_ID,
|
|
15
|
-
SCHEDULE_ID,
|
|
16
|
-
populateVdisOtherConfig,
|
|
17
|
-
resetVmOtherConfig,
|
|
18
|
-
setVmOtherConfig,
|
|
19
|
-
} from '../../_otherConfig.mjs'
|
|
12
|
+
import { DATETIME, JOB_ID, SCHEDULE_ID, VM_UUID, resetVmOtherConfig, setVmOtherConfig } from '../../_otherConfig.mjs'
|
|
20
13
|
|
|
21
|
-
const { warn } = createLogger('xo:backups:AbstractXapi')
|
|
14
|
+
const { warn, info } = createLogger('xo:backups:AbstractXapi')
|
|
22
15
|
|
|
23
16
|
export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
24
17
|
constructor({
|
|
@@ -182,43 +175,113 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
182
175
|
}
|
|
183
176
|
}
|
|
184
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
|
+
|
|
185
189
|
async _fetchJobSnapshots() {
|
|
186
190
|
const jobId = this._jobId
|
|
187
|
-
const vmRef = this._vm.$ref
|
|
188
191
|
const xapi = this._xapi
|
|
189
192
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
const vdiCandidates = {}
|
|
194
|
+
|
|
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
|
+
})
|
|
195
203
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
200
211
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
(
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
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
|
|
220
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
|
+
})
|
|
221
282
|
}
|
|
283
|
+
|
|
284
|
+
this._jobSnapshotVdis = Object.values(vdiCandidates)
|
|
222
285
|
}
|
|
223
286
|
|
|
224
287
|
async _removeUnusedSnapshots() {
|
|
@@ -268,7 +331,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
268
331
|
// since we won't be able to remove an attached VDI
|
|
269
332
|
assert.strictEqual(vbds.length, 1, 'VDI must be free or attached to exactly one VM')
|
|
270
333
|
const vdiVm = vbds[0].$VM
|
|
271
|
-
if (
|
|
334
|
+
if (vdiVm.$snapshot_of === undefined) {
|
|
272
335
|
// don't delete a VM (especially a control domain)
|
|
273
336
|
warn(
|
|
274
337
|
`VM ${vdiVm.uuid} (${vdiVm.name_label}) linked to VDI ${vdi.uuid} (${vdi.name_label}) should be a snapshot`
|
|
@@ -315,8 +378,9 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
315
378
|
// going back to a previous version of XO not supporting CBT will create a full backup
|
|
316
379
|
// this will only do something after snapshot and transfer
|
|
317
380
|
if (
|
|
381
|
+
this._exportedVm !== undefined &&
|
|
318
382
|
// don't modify the VM
|
|
319
|
-
this._exportedVm
|
|
383
|
+
this._exportedVm.$snapshot_of !== undefined &&
|
|
320
384
|
// user don't want to keep the snapshot data
|
|
321
385
|
this._settings.snapshotRetention === 0 &&
|
|
322
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
|
},
|
|
@@ -32,7 +32,7 @@
|
|
|
32
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,10 +48,10 @@
|
|
|
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.
|
|
54
|
+
"vhd-lib": "^4.14.7",
|
|
55
55
|
"xen-api": "^4.7.6",
|
|
56
56
|
"yazl": "^2.5.1"
|
|
57
57
|
},
|
|
@@ -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": {
|