@xen-orchestra/backups 0.67.2 → 0.68.1

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'
@@ -613,6 +613,11 @@ export class RemoteAdapter {
613
613
  // if cache is missing or broken => regenerate it and return
614
614
 
615
615
  async _readCacheListVmBackups(vmUuid) {
616
+ // immutable remote can't use any caching
617
+ // since the cache file may be non modifiable
618
+ if (this._handler.isImmutable()) {
619
+ return this.#getCacheableDataListVmBackups(`${BACKUP_DIR}/${vmUuid}`)
620
+ }
616
621
  const path = this.#getVmBackupsCache(vmUuid)
617
622
 
618
623
  const cache = await this._readCache(path)
@@ -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
 
@@ -291,10 +291,16 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
291
291
  await xapi.VTPM_create({ VM: vmRef, contents })
292
292
  })
293
293
  )
294
-
294
+ const vm = await xapi.getRecord('VM', vmRef)
295
295
  await Promise.all([
296
- incrementalVm.vm.ha_always_run && xapi.setField('VM', vmRef, 'ha_always_run', true),
297
- xapi.setField('VM', vmRef, 'name_label', incrementalVm.vm.name_label),
296
+ vmRecord.ha_always_run && xapi.setField('VM', vmRef, 'ha_always_run', true),
297
+ xapi.setField('VM', vmRef, 'name_label', vmRecord.name_label),
298
+ // correctly unlock the VM and reapply the target blocked operations
299
+ vm.update_blocked_operations({
300
+ start: null,
301
+ start_on: null,
302
+ ...vmRecord.blocked_operations,
303
+ }),
298
304
  ])
299
305
 
300
306
  return vmRef
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)
@@ -3,22 +3,18 @@ import groupBy from 'lodash/groupBy.js'
3
3
  import { createLogger } from '@xen-orchestra/log'
4
4
  import ignoreErrors from 'promise-toolbox/ignoreErrors'
5
5
  import { asyncMap } from '@xen-orchestra/async-map'
6
+ import { asyncEach } from '@vates/async-each'
6
7
  import { decorateMethodsWith } from '@vates/decorate-with'
7
8
  import { defer } from 'golike-defer'
8
9
 
9
10
  import { getOldEntries } from '../../_getOldEntries.mjs'
10
11
  import { Task } from '../../Task.mjs'
11
12
  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'
13
+ import { DATETIME, JOB_ID, SCHEDULE_ID, VM_UUID, resetVmOtherConfig, setVmOtherConfig } from '../../_otherConfig.mjs'
20
14
 
21
- const { warn } = createLogger('xo:backups:AbstractXapi')
15
+ const { warn, info } = createLogger('xo:backups:AbstractXapi')
16
+
17
+ const TEMP_SNAPSHOT_NAME = 'xo-backup-temp-snapshot-name'
22
18
 
23
19
  export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
24
20
  constructor({
@@ -163,7 +159,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
163
159
 
164
160
  const snapshotRef = await vm[settings.checkpointSnapshot ? '$checkpoint' : '$snapshot']({
165
161
  ignoredVdisTag: '[NOBAK]',
166
- name_label: this._getSnapshotNameLabel(vm),
162
+ name_label: TEMP_SNAPSHOT_NAME,
167
163
  unplugVusbs: true,
168
164
  })
169
165
  this.timestamp = Date.now()
@@ -173,6 +169,9 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
173
169
  scheduleId: this.scheduleId,
174
170
  vmUuid: vm.uuid,
175
171
  })
172
+ const snapshot = await xapi.getRecord('VM', snapshotRef)
173
+ await snapshot.set_name_label(this._getSnapshotNameLabel(vm))
174
+ // reload data to ensure it is up to date with the new name label
176
175
  this._exportedVm = await xapi.getRecord('VM', snapshotRef)
177
176
  return this._exportedVm.uuid
178
177
  })
@@ -182,43 +181,113 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
182
181
  }
183
182
  }
184
183
 
184
+ // handle snapshot by VDI since snapshot of cbtDestroySnapshotData jobs
185
+ // aren't attached to any VM
186
+
187
+ // look at all the vdi snapshots ( snapshot_of not empty )
188
+ // with the same vm_uuid and job_uuid
189
+ // for cbt_metadata list them unconditionnaly to remove older one
190
+ // for other: only list them if they are attached to a VM snapshot
191
+ // and if this vm snapshot is also part of the backup
192
+ // ensure they are attached to only one vm snapshot
193
+ // ensure any VM-snapshot harvested by this has all its disk harvested (no mix of vdi snapshot from this job and not)
194
+
185
195
  async _fetchJobSnapshots() {
186
196
  const jobId = this._jobId
187
- const vmRef = this._vm.$ref
188
197
  const xapi = this._xapi
189
198
 
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'))
199
+ const vdiCandidates = {}
200
+
201
+ Object.values(xapi.objects.indexes.type.VDI)
202
+ .filter(_ => !!_) // filter nullish
203
+ .filter(({ other_config, $snapshot_of }) => {
204
+ return $snapshot_of !== undefined && other_config[JOB_ID] === jobId && other_config[VM_UUID] === this._vm.uuid
205
+ })
206
+ .forEach(vdi => {
207
+ vdiCandidates[vdi.uuid] = vdi
208
+ })
209
+
210
+ // check that user snapshots are clean
195
211
 
196
- const vmSnapshots = []
197
- vmSnapshotsOtherConfig.forEach((other_config, i) => {
198
- if (other_config[JOB_ID] === jobId) {
199
- vmSnapshots.push({ other_config, $ref: vmSnapshotsRef[i] })
212
+ for (const vdi of Object.values(vdiCandidates)) {
213
+ // cbt metadata are always considered linked to a backup job
214
+ // if they have the right other_config
215
+ if (vdi.type === 'cbt_metadata') {
216
+ continue
200
217
  }
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
- }
218
+ const vbds = vdi.$VBDs
219
+ .filter(({ $VM }) => !!$VM) // filter empty VMs
220
+ .filter(({ $VM }) => $VM.is_control_domain === false) // do not handle control domain
221
+ if (vbds.length === 0) {
222
+ // orphan vdi snapshot
223
+ info(
224
+ `disk snapshot ${vdi.name_label} is orphan or attached only to control domain,
225
+ it will be removed at the end of a successful backup run`,
226
+ { vdi, attachedto: vdi.$VBDs.map(vbd => vbd?.$VM) }
227
+ )
228
+ continue
229
+ }
230
+ const userVms = vbds.map(({ $VM }) => $VM)
231
+ if (vbds.length > 1) {
232
+ warn(
233
+ `vdi ${vdi.name_label} (${vdi.uuid}) is linked to multipe vms : ${userVms.map(({ name_label, uuid }) => `${name_label} ${uuid}`).join(', ')}.
234
+ This disk snapshot will be excluded from the backup cleaning`,
235
+ { vdi, userVms }
236
+ )
237
+ delete vdiCandidates[vdi.uuid]
238
+ continue
220
239
  }
240
+
241
+ const vm = vbds[0].$VM
242
+
243
+ // xapi has some issue regarding vdi snapshot attached to non snapshot VM
244
+ // it can also be some user action forcibely linking a vdi snapshot to a vm
245
+ // => we exclude these from the backup processing
246
+ if (vm.$snapshot_of === undefined) {
247
+ warn(
248
+ `vdi ${vdi.name_label} (${vdi.uuid}) is a snapshot linked to a non snapshot vm ${vm.name_label} ${vm.uuid}.
249
+ This disk snapshot will be excluded from the backup cleaning`,
250
+ { vdi, vm }
251
+ )
252
+ delete vdiCandidates[vdi.uuid]
253
+ continue
254
+ }
255
+
256
+ // vdi is attached only to a snapshot that is not a backup snapshot
257
+ // we don't check scheduleId since we are looking for all the snapshot of this job
258
+ // => excludes from the list to be cleared
259
+ if (
260
+ vm.other_config[DATETIME] !== vdi.other_config[DATETIME] ||
261
+ vm.other_config[JOB_ID] !== vdi.other_config[JOB_ID] ||
262
+ vm.other_config[VM_UUID] !== vdi.other_config[VM_UUID]
263
+ ) {
264
+ warn(
265
+ `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.
266
+ This disk snapshot will be excluded from the backup cleaning`,
267
+ { vdi, vm }
268
+ )
269
+ delete vdiCandidates[vdi.uuid]
270
+ continue
271
+ }
272
+
273
+ // check if all the disks of these VM snapshot have been harvested
274
+ // if not => remove it from the list to ensure we won't half destroy VM later
275
+ vm.$VBDs
276
+ .filter(({ $VDI }) => !!$VDI) // filter missing keys
277
+ .filter(({ $VDI }) => $VDI && vdiCandidates[$VDI.uuid] === undefined)
278
+ .forEach(({ $VDI: outOfSnapshotsVdi, ...other }) => {
279
+ warn(
280
+ `vdi ${vdi.name_label} ${vdi.uuid} is recognized as a snapshot of the backup job,
281
+ linked to vm ${vm.name_label} ${vm.uuid} but vdi ${outOfSnapshotsVdi.name_label} ${outOfSnapshotsVdi.uuid}
282
+ is not linked to the job. This disk snapshot will be excluded from the backup cleaning`,
283
+ { vdi, vm, vbds, outOfSnapshotsVdi }
284
+ )
285
+ // this will be called multiple time but it is not really an issue
286
+ delete vdiCandidates[vdi.uuid]
287
+ })
221
288
  }
289
+
290
+ this._jobSnapshotVdis = Object.values(vdiCandidates)
222
291
  }
223
292
 
224
293
  async _removeUnusedSnapshots() {
@@ -268,7 +337,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
268
337
  // since we won't be able to remove an attached VDI
269
338
  assert.strictEqual(vbds.length, 1, 'VDI must be free or attached to exactly one VM')
270
339
  const vdiVm = vbds[0].$VM
271
- if (!vdiVm.is_a_snapshot) {
340
+ if (vdiVm.$snapshot_of === undefined) {
272
341
  // don't delete a VM (especially a control domain)
273
342
  warn(
274
343
  `VM ${vdiVm.uuid} (${vdiVm.name_label}) linked to VDI ${vdi.uuid} (${vdi.name_label}) should be a snapshot`
@@ -308,6 +377,11 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
308
377
  }
309
378
  })
310
379
  })
380
+
381
+ // list and remove the snapshot were the jobs failed between
382
+ // makesnapshot and update_other_config
383
+ const snapshots = this._vm.$snapshots.filter(_ => !!_).filter(({ name_label }) => name_label === TEMP_SNAPSHOT_NAME)
384
+ await asyncEach(snapshots, snapshot => snapshot.$destroy())
311
385
  }
312
386
 
313
387
  async _removeSnapshotData() {
@@ -315,8 +389,9 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
315
389
  // going back to a previous version of XO not supporting CBT will create a full backup
316
390
  // this will only do something after snapshot and transfer
317
391
  if (
392
+ this._exportedVm !== undefined &&
318
393
  // don't modify the VM
319
- this._exportedVm?.is_a_snapshot &&
394
+ this._exportedVm.$snapshot_of !== undefined &&
320
395
  // user don't want to keep the snapshot data
321
396
  this._settings.snapshotRetention === 0 &&
322
397
  // 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,7 +1,6 @@
1
- import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
1
+ import { asyncMapSettled } from '@xen-orchestra/async-map'
2
2
  import ignoreErrors from 'promise-toolbox/ignoreErrors'
3
3
 
4
- import { formatFilenameDate } from '../../_filenameDate.mjs'
5
4
  import { getOldEntries } from '../../_getOldEntries.mjs'
6
5
  import { importIncrementalVm } from '../../_incrementalVm.mjs'
7
6
  import { Task } from '../../Task.mjs'
@@ -9,8 +8,18 @@ import { Task } from '../../Task.mjs'
9
8
  import { AbstractIncrementalWriter } from './_AbstractIncrementalWriter.mjs'
10
9
  import { MixinXapiWriter } from './_MixinXapiWriter.mjs'
11
10
  import { listReplicatedVms } from './_listReplicatedVms.mjs'
12
- import { COPY_OF, setVmOtherConfig, BASE_DELTA_VDI } from '../../_otherConfig.mjs'
11
+ import {
12
+ COPY_OF,
13
+ setVmOtherConfig,
14
+ BASE_DELTA_VDI,
15
+ JOB_ID,
16
+ SCHEDULE_ID,
17
+ REPLICATED_TO_SR_UUID,
18
+ DATETIME,
19
+ VM_UUID,
20
+ } from '../../_otherConfig.mjs'
13
21
  import assert from 'node:assert'
22
+ import { formatFilenameDate } from '../../_filenameDate.mjs'
14
23
 
15
24
  export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
16
25
  async checkBaseVdis(baseUuidToSrcVdi) {
@@ -94,12 +103,28 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
94
103
  return asyncMapSettled(this._oldEntries, vm => vm.$destroy())
95
104
  }
96
105
 
97
- #decorateVmMetadata(backup) {
106
+ #decorateVmMetadata(backup, timestamp) {
98
107
  const { _warmMigration } = this._settings
99
108
  const sr = this._sr
100
109
  const vm = backup.vm
110
+ const job = this._job
111
+ const scheduleId = this._scheduleId
101
112
 
113
+ vm.name_label = `${vm.name_label} - ${job.name} - (${formatFilenameDate(timestamp)})`
114
+ // update other_config data as soon as possible to ensure the next job
115
+ // will be able to detect any partial transfer and lean them
102
116
  vm.other_config[COPY_OF] = vm.uuid
117
+ vm.other_config[JOB_ID] = job.id
118
+ vm.other_config[SCHEDULE_ID] = scheduleId
119
+ vm.other_config[REPLICATED_TO_SR_UUID] = sr.uuid
120
+ // set the timestamp in the past to ensure any incomplete VM will be deleted on next run
121
+ vm.other_config[DATETIME] = formatFilenameDate(0)
122
+
123
+ vm.blocked_operations = {
124
+ start: 'Start operation for this vm is blocked, clone it if you want to use it.',
125
+ start_on: 'Start operation for this vm is blocked, clone it if you want to use it.',
126
+ }
127
+
103
128
  if (!_warmMigration) {
104
129
  vm.tags.push('Continuous Replication')
105
130
  }
@@ -118,6 +143,11 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
118
143
 
119
144
  Object.values(backup.vdis).forEach(vdi => {
120
145
  vdi.other_config[COPY_OF] = vdi.uuid
146
+ vdi.other_config[JOB_ID] = job.id
147
+ vdi.other_config[SCHEDULE_ID] = scheduleId
148
+ vdi.other_config[REPLICATED_TO_SR_UUID] = sr.uuid
149
+ vdi.other_config[VM_UUID] = vm.uuid
150
+
121
151
  if (sourceVdiUuids.length > 0) {
122
152
  const baseReplicatedTo = replicatedVdis.filter(
123
153
  replicatedVdi => replicatedVdi.other_config[COPY_OF] === vdi.other_config[BASE_DELTA_VDI]
@@ -144,12 +174,11 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
144
174
  const sr = this._sr
145
175
  const job = this._job
146
176
  const scheduleId = this._scheduleId
147
-
148
177
  const { uuid: srUuid, $xapi: xapi } = sr
149
-
178
+
150
179
  let targetVmRef
151
180
  await Task.run({ name: 'transfer' }, async () => {
152
- targetVmRef = await importIncrementalVm(this.#decorateVmMetadata(deltaExport), sr)
181
+ targetVmRef = await importIncrementalVm(this.#decorateVmMetadata(deltaExport, timestamp), sr)
153
182
  // size is mandatory to ensure the task have the right data
154
183
  return {
155
184
  size: Object.values(deltaExport.disks).reduce(
@@ -166,15 +195,8 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
166
195
  !_warmMigration &&
167
196
  targetVm.ha_restart_priority !== '' &&
168
197
  Promise.all([targetVm.set_ha_restart_priority(''), targetVm.add_tags('HA disabled')]),
169
- targetVm.set_name_label(`${vm.name_label} - ${job.name} - (${formatFilenameDate(timestamp)})`),
170
- asyncMap(['start', 'start_on'], op =>
171
- targetVm.update_blocked_operations(
172
- op,
173
- 'Start operation for this vm is blocked, clone it if you want to use it.'
174
- )
175
- ),
176
198
  setVmOtherConfig(xapi, targetVmRef, {
177
- timestamp,
199
+ timestamp, // updated at the end to mark the transfer as complete
178
200
  jobId: job.id,
179
201
  scheduleId,
180
202
  vmUuid: vm.uuid,
@@ -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
@@ -20,9 +20,7 @@ export function listReplicatedVms(xapi, scheduleOrJobId, srUuid, vmUuid) {
20
20
  'start' in object.blocked_operations &&
21
21
  (oc[JOB_ID] === scheduleOrJobId || oc[SCHEDULE_ID] === scheduleOrJobId) &&
22
22
  oc[REPLICATED_TO_SR_UUID] === srUuid &&
23
- (oc[VM_UUID] === vmUuid ||
24
- // 2018-03-28, JFT: to catch VMs replicated before this fix
25
- oc[VM_UUID] === undefined)
23
+ oc[VM_UUID] === vmUuid
26
24
  ) {
27
25
  vms[object.$id] = object
28
26
  }
@@ -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.1",
12
12
  "engines": {
13
13
  "node": ">=14.18"
14
14
  },
@@ -26,13 +26,13 @@
26
26
  "@vates/disposable": "^0.1.6",
27
27
  "@vates/fuse-vhd": "^2.1.2",
28
28
  "@vates/generator-toolbox": "^1.1.0",
29
- "@vates/nbd-client": "^3.2.2",
29
+ "@vates/nbd-client": "^3.2.3",
30
30
  "@vates/parse-duration": "^0.1.1",
31
31
  "@xen-orchestra/async-map": "^0.1.2",
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.5"
66
66
  },
67
67
  "license": "AGPL-3.0-or-later",
68
68
  "author": {