@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 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'
@@ -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(date.date() - firstDayOfWeek)
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
- * return the entries too old to be kept
60
- * if multiple entries are i the same time bucket : keep only the most recent one
61
- * if an entry is valid in any of the bucket OR the minRetentionCount : keep it
62
- * if a bucket is completely empty : it does not count as one, thus it may extend the retention
63
- * @returns Array<Backup>
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
- export function getOldEntries(minRetentionCount, entries, { longTermRetention = {}, timezone } = {}) {
66
- assert.strictEqual(
67
- typeof minRetentionCount,
68
- 'number',
69
- `minRetentionCount must be a number, got ${JSON.stringify(minRetentionCount)}`
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({ minRetentionCount, longTermRetention, timezone })}`
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
- if (entry.timestamp !== undefined) {
109
- // we go through the entries from the last (most recent) to the first (oldest)
110
- assert.ok(
111
- previousTimestamp === -1 || entry.timestamp < previousTimestamp,
112
- `entries must be sorted in desc order ${new Date(entry.timestamp)} , previous : > ${new Date(previousTimestamp)} `
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
- previousTimestamp = entry.timestamp
115
- const entryDate = dateCreator(entry.timestamp)
116
- for (const [duration, { remaining, lastMatchingBucket, formatter }] of Object.entries(dateBuckets)) {
117
- const bucket = formatter(entryDate)
118
- if (lastMatchingBucket !== bucket) {
119
- if (remaining === 0) {
120
- continue
121
- }
122
- dateBuckets[duration].lastMatchingBucket = bucket
123
- dateBuckets[duration].remaining -= 1
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].entries[bucket] = entry
181
+ dateBuckets[duration].lastMatchingBucket = bucket
182
+ dateBuckets[duration].remaining -= 1
126
183
  }
127
- } else {
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
- // also keep it on retention
138
- if (i >= nb - minRetentionCount) {
139
- minDurationEntries.push(entry)
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
- const kept = new Set(minDurationEntries)
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 never completely full
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
- maxStreamLength += vdi.virtual_size
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
- // to ensure compatibility with snapshots older than CBT implementation
191
- // update vdi data to ensure the vdi are correctly fetched in _jobSnapshotVdis
192
- // remove by then end of 2024
193
- const vmSnapshotsRef = await xapi.getField('VM', vmRef, 'snapshots')
194
- const vmSnapshotsOtherConfig = await asyncMap(vmSnapshotsRef, ref => xapi.getField('VM', ref, 'other_config'))
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
- const vmSnapshots = []
197
- vmSnapshotsOtherConfig.forEach((other_config, i) => {
198
- if (other_config[JOB_ID] === jobId) {
199
- vmSnapshots.push({ other_config, $ref: vmSnapshotsRef[i] })
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
- await Promise.all(vmSnapshots.map(snapshot => populateVdisOtherConfig(xapi, snapshot.$ref)))
203
- // end of compatibility handling
204
-
205
- // handle snapshot by VDI
206
- this._jobSnapshotVdis = []
207
- const srcVdis = await xapi.getRecords('VDI', await this._vm.$getDisks())
208
- for (const srcVdi of srcVdis) {
209
- const snapshots = await xapi.getRecords('VDI', srcVdi.snapshots)
210
- for (const snapshot of snapshots) {
211
- // only keep the snapshot related to this backup job
212
- // and only if the job is still using purge snapshot data or if the disk
213
- // is not a cbt metadata disk ( expect a type: user for normal disks)
214
- if (
215
- snapshot.other_config[JOB_ID] === jobId &&
216
- (this._settings.cbtDestroySnapshotData || snapshot.type !== 'cbt_metadata')
217
- ) {
218
- this._jobSnapshotVdis.push(snapshot)
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 (!vdiVm.is_a_snapshot) {
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?.is_a_snapshot &&
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
@@ -45,7 +45,7 @@ function formatVmBackup(backup) {
45
45
  name_label: backup.vm.name_label,
46
46
  tags: backup.vm.tags,
47
47
  },
48
-
48
+ tags: backup.tags ?? [],
49
49
  differencingVhds,
50
50
  dynamicVhds,
51
51
  withMemory,
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.67.2",
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.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": "^6.1.15",
51
+ "tar": "^7.5.3",
52
52
  "uuid": "^9.0.0",
53
53
  "value-matcher": "^0.2.0",
54
- "vhd-lib": "^4.14.6",
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.3"
65
+ "@xen-orchestra/xapi": "^8.6.4"
66
66
  },
67
67
  "license": "AGPL-3.0-or-later",
68
68
  "author": {