Compare commits

...

12 Commits

Author SHA1 Message Date
Florent BEAUCHAMP
786e05e775 feat(hyper-v): technical exploration 2026-02-13 14:57:47 +00:00
Florent BEAUCHAMP
6cce923cd6 feat(backups): rename to distributed backups and replications 2026-02-13 13:20:56 +00:00
Bastien Nollet
50cc40dd62 Merge branch 'master' into feat_sobr 2026-02-12 11:07:08 +01:00
Florent BEAUCHAMP
1ea6bfef54 more code cleanup 2026-02-12 09:38:23 +00:00
Florent BEAUCHAMP
3396c5cad7 code cleanup 2026-02-12 09:36:25 +00:00
Florent BEAUCHAMP
04c1af5665 fix: ensure there is at least one target for an aggregated writer 2026-02-12 09:29:36 +00:00
Florent BEAUCHAMP
112ac2f9d0 fix spread conditions 2026-02-12 09:25:30 +00:00
Florent BEAUCHAMP
5c7fee5029 feat(xo-web): front for aggregated backups
* show a toggle on remote/sr selectors
* toggle is disabled if 1 or less targets are selected
* for backup and mirror backup forms
* show the details in the job list
2026-02-11 13:28:14 +00:00
Florent BEAUCHAMP
9b51fc6eb7 feat(backups): support aggregated writers as mirror's target
not that the main writer is choosen once per run per vm

if a vm has multiple backups to transfer during the same run, then
they will all go to teh same remote
2026-02-06 17:02:29 +00:00
Florent BEAUCHAMP
5e076a56b3 feat(backups): plug aggregated backups
Note that this branch will always do spred backup/replication to ease testing
2026-02-06 17:02:25 +00:00
Florent BEAUCHAMP
af26b7568c feat(backup): implement aggregated replication classes
same approach as for backup but for replication
the rules are the same
2026-02-06 17:02:25 +00:00
Florent BEAUCHAMP
83019c39bd feat(backup): implement aggregated backup classes
The deletion of old backups is done globally
For Full backups:
 * retention is computed on the total of the target
 * choose the storage with the most free size ( if possible, since some backup repositories don't have any size used/ free )
 * one vm is written on the same target ( xva, index, checksum )
 * healthcheck ( if enabled ) will run only on the target that received the data
 * the cleanup of old backups/replication is done on the right target ( but considering the full lenght across all the targets )

For Incremental a backup chain is constrained to the same target
2026-02-06 17:02:25 +00:00
28 changed files with 1549 additions and 68 deletions

View File

@@ -44,7 +44,7 @@ const IMMUTABILITY_METADATA_FILENAME = '/immutability.json'
const { debug, warn } = createLogger('xo:backups:RemoteAdapter')
const compareTimestamp = (a, b) => a.timestamp - b.timestamp
export const compareTimestamp = (a, b) => a.timestamp - b.timestamp
const noop = Function.prototype

View File

@@ -2,10 +2,11 @@ import { AbstractRemote } from './_AbstractRemote.mjs'
import { FullRemoteWriter } from '../_writers/FullRemoteWriter.mjs'
import { forkStreamUnpipe } from '../_forkStreamUnpipe.mjs'
import { watchStreamSize } from '../../_watchStreamSize.mjs'
import { AggregatedFullRemoteWriter } from '../_writers/AggregatedFullRemoteWriter.mjs'
export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote {
_getRemoteWriter() {
return FullRemoteWriter
_getRemoteWriters() {
return [FullRemoteWriter, AggregatedFullRemoteWriter]
}
_filterTransferList(transferList) {

View File

@@ -5,12 +5,14 @@ import { FullRemoteWriter } from '../_writers/FullRemoteWriter.mjs'
import { FullXapiWriter } from '../_writers/FullXapiWriter.mjs'
import { watchStreamSize } from '../../_watchStreamSize.mjs'
import { AbstractXapi } from './_AbstractXapi.mjs'
import { AggregatedFullRemoteWriter } from '../_writers/AggregatedFullRemoteWriter.mjs'
import { AggregatedFullXapiWriter } from '../_writers/AggregatedFullXapiWriter.mjs'
const { debug } = createLogger('xo:backups:FullXapiVmBackup')
export const FullXapi = class FullXapiVmBackupRunner extends AbstractXapi {
_getWriters() {
return [FullRemoteWriter, FullXapiWriter]
return [FullRemoteWriter, FullXapiWriter, AggregatedFullRemoteWriter, AggregatedFullXapiWriter]
}
async _mustDoSnapshot() {

View File

@@ -11,11 +11,12 @@ import { Disposable } from 'promise-toolbox'
import { openVhd } from 'vhd-lib'
import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
import { SynchronizedDisk, ThrottledDisk } from '@xen-orchestra/disk-transform'
import { AggregatedIncrementalRemoteWriter } from '../_writers/AggregatedIncrementalRemoteWriter.mjs'
const { warn } = createLogger('xo:backups:Incrementalremote')
class IncrementalRemoteVmBackupRunner extends AbstractRemote {
_getRemoteWriter() {
return IncrementalRemoteWriter
_getRemoteWriters() {
return [IncrementalRemoteWriter, AggregatedIncrementalRemoteWriter]
}
// we'll transfer the full list if at least one backup should be transferred

View File

@@ -15,12 +15,19 @@ import {
markExportSuccessfull,
} from '../../_otherConfig.mjs'
import { ThrottledDisk, SynchronizedDisk } from '@xen-orchestra/disk-transform'
import { AggregatedIncrementalRemoteWriter } from '../_writers/AggregatedIncrementalRemoteWriter.mjs'
import { AggregatedIncrementalXapiWriter } from '../_writers/AggregatedIncrementalXapiWriter.mjs'
const { debug } = createLogger('xo:backups:IncrementalXapiVmBackup')
export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends AbstractXapi {
_getWriters() {
return [IncrementalRemoteWriter, IncrementalXapiWriter]
return [
IncrementalRemoteWriter,
IncrementalXapiWriter,
AggregatedIncrementalRemoteWriter,
AggregatedIncrementalXapiWriter,
]
}
async _mustDoSnapshot() {

View File

@@ -45,26 +45,41 @@ export const AbstractRemote = class AbstractRemoteVmBackupRunner extends Abstrac
const writers = new Set()
this._writers = writers
const RemoteWriter = this._getRemoteWriter()
extractIdsFromSimplePattern(job.remotes).forEach(remoteId => {
const adapter = remoteAdapters[remoteId]
const targetSettings = {
...settings,
...allSettings[remoteId],
}
const [BackupWriter, AggregratedBackupWriter] = this._getRemoteWriters()
if (settings.distributeBackups) {
writers.add(
new RemoteWriter({
adapter,
new AggregratedBackupWriter({
adapters: remoteAdapters,
BackupWriter,
config,
healthCheckSr,
job,
scheduleId: schedule.id,
vmUuid,
remoteId,
settings: targetSettings,
settings,
})
)
})
} else {
extractIdsFromSimplePattern(job.remotes).forEach(remoteId => {
const adapter = remoteAdapters[remoteId]
const targetSettings = {
...settings,
...allSettings[remoteId],
}
writers.add(
new BackupWriter({
adapter,
config,
healthCheckSr,
job,
scheduleId: schedule.id,
vmUuid,
remoteId,
settings: targetSettings,
})
)
})
}
const { filter } = job
if (filter === undefined) {
this._filterPredicate = () => true

View File

@@ -82,48 +82,84 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
const writers = new Set()
this._writers = writers
const [BackupWriter, ReplicationWriter] = this._getWriters()
const [BackupWriter, ReplicationWriter, AggregratedBackupWriter, AggregratedReplicationWriter] =
this._getWriters()
const allSettings = job.settings
Object.entries(remoteAdapters).forEach(([remoteId, adapter]) => {
const targetSettings = {
...settings,
...allSettings[remoteId],
}
if (targetSettings.exportRetention !== 0) {
if (settings.exportRetention > 0) {
if (settings.distributeBackups) {
writers.add(
new BackupWriter({
adapter,
new AggregratedBackupWriter({
adapters: remoteAdapters,
BackupWriter,
config,
healthCheckSr,
job,
scheduleId: schedule.id,
vmUuid: vm.uuid,
remoteId,
settings: targetSettings,
settings,
})
)
} else {
Object.entries(remoteAdapters).forEach(([remoteId, adapter]) => {
const targetSettings = {
...settings,
...allSettings[remoteId],
}
if (targetSettings.exportRetention !== 0) {
writers.add(
new BackupWriter({
adapter,
config,
healthCheckSr,
job,
scheduleId: schedule.id,
vmUuid: vm.uuid,
remoteId,
settings: targetSettings,
})
)
}
})
}
})
srs.forEach(sr => {
const targetSettings = {
...settings,
...allSettings[sr.uuid],
}
if (targetSettings.copyRetention !== 0) {
}
if (settings.copyRetention) {
if (settings.distributeReplications) {
writers.add(
new ReplicationWriter({
new AggregratedReplicationWriter({
config,
healthCheckSr,
job,
ReplicationWriter,
scheduleId: schedule.id,
vmUuid: vm.uuid,
sr,
settings: targetSettings,
srs,
settings,
})
)
} else {
srs.forEach(sr => {
const targetSettings = {
...settings,
...allSettings[sr.uuid],
}
if (targetSettings.copyRetention !== 0) {
writers.add(
new ReplicationWriter({
config,
healthCheckSr,
job,
scheduleId: schedule.id,
vmUuid: vm.uuid,
sr,
settings: targetSettings,
})
)
}
})
}
})
}
}
}

View File

@@ -0,0 +1,38 @@
import { AbstractAggregatedRemoteWriter } from './_AbstractAggregatedRemoteWriter.mjs'
export class AggregatedFullRemoteWriter extends AbstractAggregatedRemoteWriter {
async deleteOldBackupsOnAdapter(adapter, backups) {
await adapter.deleteFullVmBackups(backups)
}
// take the lock on every adapters with this VM
// cleanup if deleteFirst is selected
async beforeBackup() {
await this.setupWriters()
await Promise.all(this.writers.map(writer => writer.beforeBackup()))
await this.setOldBackups()
if (this.props.settings.deleteFirst) {
await this.deleteOldBackups()
}
}
async run(props) {
const mainAdapter = await this.computeAdapterCandidate()
this.mainWriter = this.getWriterByAdapter(mainAdapter)
return this.mainWriter.run(props)
}
// cleanup if deleteFirst is NOT selected
// release the lock on every adapters with this VM
async afterBackup() {
if (!this.props.settings.deleteFirst) {
await this.deleteOldBackups()
}
await Promise.all(this.writers.map(writer => writer.afterBackup()))
}
healthCheck() {
return this.mainWriter.healthCheck()
}
}

View File

@@ -0,0 +1,31 @@
import { AbstractAggregatedXapiWriter } from './_AbstractAggregatedXapiWriter.mjs'
export class AggregatedFullXapiWriter extends AbstractAggregatedXapiWriter {
// delete replica if deleteFirst is selected
async beforeBackup() {
await this.setupWriters()
await Promise.all(this.writers.map(writer => writer.beforeBackup()))
this.setOldReplicaList()
if (this.props.settings.deleteFirst) {
await this.deleteOldReplicas()
}
}
async run(props) {
const mainSr = await this.computeSRCandidate()
this.mainWriter = this.getWriterBySr(mainSr)
await this.mainWriter.run(props)
}
// delete replica if not using deleteFirst
async afterBackup() {
if (!this.props.settings.deleteFirst) {
await this.deleteOldReplicas()
}
await Promise.all(this.writers.map(writer => writer.afterBackup()))
}
healthCheck() {
return this.mainWriter.healthCheck()
}
}

View File

@@ -0,0 +1,78 @@
import { AbstractAggregatedRemoteWriter } from './_AbstractAggregatedRemoteWriter.mjs'
export class AggregatedIncrementalRemoteWriter extends AbstractAggregatedRemoteWriter {
async deleteOldBackupsOnAdapter(adapter, backups) {
await adapter.deleteDeltaVmBackups(backups)
}
/**
*
* @param {Map<string,string>} baseUuidToSrcVdi
*/
async checkBaseVdis(baseUuidToSrcVdi) {
let selectedCopy = new Map()
for (const writer of this.writers) {
const copy = new Map(baseUuidToSrcVdi)
await writer.checkBaseVdis(copy)
if (copy.size > 0) {
// there can be multiple candidates
// we use the last one in this case
this.mainWriter = writer
selectedCopy = copy
}
}
// update the parameter with our best candidate
// that can be an empty map
baseUuidToSrcVdi.forEach(key => {
if (!selectedCopy.has(key)) {
baseUuidToSrcVdi.delete(key)
}
})
}
// take the lock on every adapters with this VM
// cleanup if deleteFirst is selected
async beforeBackup() {
await this.setupWriters()
await Promise.all(this.writers.map(writer => writer.beforeBackup()))
await this.setOldBackups()
if (this.props.settings.deleteFirst) {
await this.deleteOldBackups()
}
}
async prepare({ isFull }) {
if (this.mainWriter === undefined) {
// use the remote with the most empty space for starting a new chain
const mainAdapter = await this.computeAdapterCandidate()
this.mainWriter = this.getWriterByAdapter(mainAdapter)
}
await Promise.all(this.writers.map(writer => writer.prepare({ isFull })))
}
// write only on the main writer
transfer({ isVhdDifferencing, timestamp, deltaExport, vm, vmSnapshot }) {
return this.mainWriter.transfer({ isVhdDifferencing, timestamp, deltaExport, vm, vmSnapshot })
}
// chain only on the main writer
updateUuidAndChain({ isVhdDifferencing, vdis }) {
return this.mainWriter.updateUuidAndChain({ isVhdDifferencing, vdis })
}
// remove the backups and remove the entries
async cleanup() {
if (!this.props.settings.deleteFirst) {
await this.deleteOldBackups()
}
await Promise.all(this.writers.map(writer => writer.cleanup()))
}
// cleanVM on all, since we may want to delete a chani
// that is not on the main writer
async afterBackup() {
await Promise.all(this.writers.map(writer => writer.afterBackup()))
}
healthCheck() {
return this.mainWriter.healthCheck()
}
}

View File

@@ -0,0 +1,85 @@
import { AbstractAggregatedXapiWriter } from './_AbstractAggregatedXapiWriter.mjs'
import { createLogger } from '@xen-orchestra/log'
const { debug } = createLogger('xo:backups:AggregatedIncrementalXapiWriter')
export class AggregatedIncrementalXapiWriter extends AbstractAggregatedXapiWriter {
/**
*
* @param {Map<string,string>} baseUuidToSrcVdi
*/
async checkBaseVdis(baseUuidToSrcVdi) {
debug('checkBaseVdis', { baseUuidToSrcVdi })
let selectedCopy = new Map()
for (const writer of this.writers) {
const copy = new Map(baseUuidToSrcVdi)
await writer.checkBaseVdis(copy)
if (copy.size > 0) {
debug('checkBaseVdis found a mainwriter candidate ', writer._sr.name_label)
// there can be multiple candidates
// we use the last one in this case
this.mainWriter = writer
selectedCopy = copy
} else {
debug(writer._sr.name_label, 'is not a delta candidate')
}
}
// update the parameter with our best candidate
// that can be an empty map
baseUuidToSrcVdi.forEach(key => {
if (!selectedCopy.has(key)) {
baseUuidToSrcVdi.delete(key)
}
})
debug('checkBaseVdis', { baseUuidToSrcVdi })
}
// take the lock on every adapters with this VM
// cleanup if deleteFirst is selected
async beforeBackup() {
await this.setupWriters()
await Promise.all(this.writers.map(writer => writer.beforeBackup()))
this.setOldReplicaList()
if (this.props.settings.deleteFirst) {
await this.deleteOldReplicas()
}
}
async prepare(args) {
if (this.mainWriter === undefined) {
debug('no mainwriter found, fallback to a new SR')
// use the SR with the most empty space for starting a new chain
const mainSr = await this.computeSRCandidate()
this.mainWriter = this.getWriterBySr(mainSr)
}
await Promise.all(this.writers.map(writer => writer.prepare(args)))
}
// write only on the main writer
transfer(args) {
return this.mainWriter.transfer(args)
}
// chain only on the main writer
updateUuidAndChain(args) {
return this.mainWriter.updateUuidAndChain(args)
}
// remove the backups and remove the entries
async cleanup() {
debug('cleanup')
if (!this.props.settings.deleteFirst) {
await this.deleteOldReplicas()
}
await Promise.all(this.writers.map(writer => writer.cleanup()))
}
// cleanVM on all, since we may want to delete a chani
// that is not on the main writer
async afterBackup() {
await Promise.all(this.writers.map(writer => writer.afterBackup()))
}
healthCheck() {
return this.mainWriter.healthCheck()
}
}

View File

@@ -59,8 +59,8 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
xva: './' + dataBasename,
}
const { deleteFirst } = settings
if (deleteFirst) {
const { skipDeleteOldEntries, deleteFirst } = settings
if (!skipDeleteOldEntries && deleteFirst) {
await deleteOldBackups()
}
@@ -76,7 +76,7 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
metadata.tags = await this.getLongTermRetentionTags(metadata)
this._metadataFileName = await adapter.writeVmBackupMetadata(vm.uuid, metadata)
if (!deleteFirst) {
if (!skipDeleteOldEntries && !deleteFirst) {
await deleteOldBackups()
}

View File

@@ -46,8 +46,8 @@ export class FullXapiWriter extends MixinXapiWriter(AbstractFullWriter) {
const oldVms = getOldEntries(settings.copyRetention - 1, listReplicatedVms(xapi, scheduleId, srUuid, vm.uuid))
const deleteOldBackups = () => asyncMapSettled(oldVms, vm => xapi.VM_destroy(vm.$ref))
const { deleteFirst, _warmMigration } = settings
if (deleteFirst) {
const { deleteFirst, skipDeleteOldEntries, _warmMigration } = settings
if (!skipDeleteOldEntries && deleteFirst) {
await deleteOldBackups()
}
@@ -85,7 +85,7 @@ export class FullXapiWriter extends MixinXapiWriter(AbstractFullWriter) {
}),
])
if (!deleteFirst) {
if (!skipDeleteOldEntries && !deleteFirst) {
await deleteOldBackups()
}
}

View File

@@ -114,13 +114,13 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
oldEntries.length = maxMergedDeltasPerRun
}
if (settings.deleteFirst) {
if (settings.deleteFirst && !settings.skipDeleteOldEntries) {
await this._deleteOldEntries()
}
}
async cleanup() {
if (!this._settings.deleteFirst) {
if (!this._settings.deleteFirst && !this._settings.skipDeleteOldEntries) {
await this._deleteOldEntries()
}
}

View File

@@ -83,7 +83,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
this._oldEntries = getOldEntries(settings.copyRetention - 1, listReplicatedVms(xapi, scheduleId, srUuid, vmUuid))
if (settings.deleteFirst) {
if (settings.deleteFirst && settings.skipDeleteOldEntries) {
// we want to keep the baseVM when copying a delta
// even if we want to keep only one after
let mostRecentEntry
@@ -96,7 +96,9 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
}
async cleanup() {
await this._deleteOldEntries()
if (!this._settings.skipDeleteOldEntries) {
await this._deleteOldEntries()
}
}
async _deleteOldEntries() {

View File

@@ -0,0 +1,157 @@
import { asyncEach } from '@vates/async-each'
import { getOldEntries } from '../../_getOldEntries.mjs'
import { compareTimestamp } from '../../RemoteAdapter.mjs'
import { createLogger } from '@xen-orchestra/log'
const { debug } = createLogger('xo:backups:AbstractAggregatedRemoteWriter')
export const DEFAULT_AGGREGATED_REMOTE_DELETE_CONCURRENCY = 2
export class AbstractAggregatedRemoteWriter {
#BackupWriter
#adapters
get adapters() {
return this.#adapters
}
mainWriter
#props
get props() {
return this.#props
}
#oldBackups
#writers = []
get writers() {
return this.#writers
}
constructor({ adapters, BackupWriter, ...props }) {
debug('instantiate AggregatedFullRemoteWriter')
if (Object.keys(adapters).length < 1) {
throw new Error('AggregatedRemoteWriter need at least one backup repository to work ')
}
this.#props = props
this.#adapters = adapters
this.#BackupWriter = BackupWriter
}
getWriterByAdapter(adapter) {
for (const writer of this.#writers) {
if (writer._adapter === adapter) {
return writer
}
}
throw new Error("Can't find writer for adapter")
}
/**
* select the best adapter candidate to write this backup
* if all the adapter infos expose an available info use the one with the more free space
* if none of the adapter exposes this info : choose at random
* if mixed : throw an error
* @returns {RemoteAdapter}
*/
async computeAdapterCandidate() {
const adapters = Object.values(this.#adapters)
const infos = await Promise.all(
adapters.map(async adapter => {
const infos = await adapter._handler.getInfo()
return { adapter, infos }
})
)
const withLimit = infos.filter(({ infos: { available } }) => available > 0).length
if (withLimit !== 0 && withLimit !== infos.length) {
throw new Error(`an Aggregate remote can't use a mix of limited and unlimited storages`)
}
let candidates
if (withLimit === 0) {
debug('no storage limit , choose at random')
candidates = Object.values(this.adapters)
} else {
debug('storage limit , choose at random between those with the max space avilable')
let maxSpace = 0
infos.forEach(({ infos: { available } }) => {
if (available > maxSpace) {
maxSpace = available
}
})
// candidates are the adapter with the same free space , equal to the max free space
candidates = infos.filter(({ infos }) => infos.available === maxSpace).map(({ adapter }) => adapter)
}
const adapterCandidate = candidates[Math.floor(Math.random() * candidates.length)]
debug('adapter candidate is selected ', adapterCandidate._handler._remote.id)
return adapterCandidate
}
async setupWriters() {
debug('Setup writers ')
const settings = this.#props.settings
const allSettings = this.#props.job.settings
Object.entries(this.#adapters).forEach(([remoteId, adapter]) => {
const targetSettings = {
...settings,
...allSettings[remoteId],
skipDeleteOldEntries: true, // delete is handled globally
}
if (targetSettings.exportRetention !== 0) {
const writer = new this.#BackupWriter({
...this.#props,
adapter,
remoteId,
settings: targetSettings,
})
this.#writers.push(writer)
}
})
}
async getEntriesPerAdapter(adapter) {
const scheduleId = this.#props.scheduleId
const vmUuid = this.#props.vmUuid
return (await adapter.listVmBackups(vmUuid, _ => _.scheduleId === scheduleId)).map(entry => ({
...entry,
adapter,
}))
}
async setOldBackups() {
const entriesPerAdapter = await Promise.all(
Object.values(this.#adapters).map(adapter => this.getEntriesPerAdapter(adapter))
)
const entries = entriesPerAdapter
.flat(1)
.filter(_ => !!_)
.sort(compareTimestamp)
const settings = this.#props.settings
this.#oldBackups = getOldEntries(settings.exportRetention - 1, entries, {
longTermRetention: settings.longTermRetention,
timezone: settings.timezone,
})
debug(`got ${this.#oldBackups.length} old backup(s) to delete `)
}
async deleteOldBackupsOnAdapter(adapter, backup) {
throw new Error('not implemented')
}
async deleteOldBackups() {
const byAdapters = new Map()
this.#oldBackups.forEach(({ adapter, ...backup }) => {
const current = byAdapters.has(adapter) ? byAdapters.get(adapter) : []
current.push(backup)
byAdapters.set(adapter, current)
})
await asyncEach(
byAdapters.entries(),
async ([adapter, backups]) => {
debug(`delete ${backups.length} old backup(s) to delete from ${adapter._handler._remote.id}`)
await this.deleteOldBackupsOnAdapter(adapter, backups)
},
{
concurrency: this.#props.deleteConcurrency ?? DEFAULT_AGGREGATED_REMOTE_DELETE_CONCURRENCY,
}
)
}
}

View File

@@ -0,0 +1,114 @@
import { getOldEntries } from '../../_getOldEntries.mjs'
import { createLogger } from '@xen-orchestra/log'
import { compareReplicatedVmDatetime, listReplicatedVms } from './_listReplicatedVms.mjs'
import { asyncMapSettled } from '@xen-orchestra/async-map'
const { debug } = createLogger('xo:backups:AbstractAggregatedXapiWriter')
export class AbstractAggregatedXapiWriter {
#ReplicationWriter
#storageRepositories
#writers = []
get writers() {
return this.#writers
}
mainWriter
#props
get props() {
return this.#props
}
#oldVmReplicaList = []
constructor({ srs, ReplicationWriter, ...props }) {
debug('instantiate AbstractAggregatedXapiWriter')
if (srs.length < 1) {
throw new Error('AggregatedXapiWriter need at least one storage repository to work ')
}
this.#props = props
this.#ReplicationWriter = ReplicationWriter
this.#storageRepositories = srs
}
getWriterBySr(storageRepository) {
for (const writer of this.#writers) {
if (writer._sr === storageRepository) {
return writer
}
}
throw new Error("Can't find writer for storage repository")
}
async computeSRCandidate() {
let maxSpace = 0
this.#storageRepositories.forEach(({ physical_utilisation, physical_size }) => {
if (physical_size - physical_utilisation > maxSpace) {
maxSpace = physical_size - physical_utilisation
}
})
debug(`computeSRCandidate found the max free space ${maxSpace} for vm${this.#props.vmUuid}`)
const srCandidates = this.#storageRepositories.filter(
({ physical_utilisation, physical_size }) => maxSpace === physical_size - physical_utilisation
)
// one at random from the sr with the most free space
const candidate = srCandidates[Math.floor(Math.random() * srCandidates.length)]
debug(
`computeSRCandidate found ${srCandidates.length} sr with ${maxSpace} free size, and chose ${candidate.name_label} for vm${this.#props.vmUuid}`
)
return candidate
}
setOldReplicaList() {
debug(`setOldReplicaList for vm ${this.#props.vmUuid}`)
const scheduleId = this.#props.scheduleId
const vmUuid = this.#props.vmUuid
const settings = this.#props.settings
const replicatedVms = this.#storageRepositories
.map(sr => {
return listReplicatedVms(sr.$xapi, scheduleId, sr.uuid, vmUuid)
})
.flat(1)
.filter(_ => !!_)
.sort(compareReplicatedVmDatetime)
this.#oldVmReplicaList = getOldEntries(settings.copyRetention - 1, replicatedVms)
debug(
`setOldReplicaList found ${replicatedVms.length} replica, and will delete ${this.#oldVmReplicaList.length} (retention ${settings.copyRetention}) for vm ${this.#props.vmUuid}`
)
}
async deleteOldReplicas() {
debug(`deleteOldReplicas will delete ${this.#oldVmReplicaList.length} replicated vms for vm ${this.#props.vmUuid}`)
// destroy the VM on the right xapi
await asyncMapSettled(this.#oldVmReplicaList, async vm => {
debug('will delete old replica', vm.name_label)
await vm.$xapi.VM_destroy(vm.$ref)
})
debug(`deleteOldReplicas deleted ${this.#oldVmReplicaList.length} replicated vms for vm ${this.#props.vmUuid}`)
}
async setupWriters() {
debug('Setup writers ')
const settings = this.#props.settings
const allSettings = this.#props.job.settings
this.#storageRepositories.forEach(storageRepository => {
const targetSettings = {
...settings,
...allSettings[storageRepository.uuid],
skipDeleteOldEntries: true, // delete is handled globally
}
if (targetSettings.copyRetention !== 0) {
const writer = new this.#ReplicationWriter({
...this.#props,
sr: storageRepository,
settings: targetSettings,
})
this.#writers.push(writer)
}
})
}
}

View File

@@ -5,10 +5,18 @@ const getReplicatedVmDatetime = vm => {
return datetime
}
const compareReplicatedVmDatetime = (a, b) => (getReplicatedVmDatetime(a) < getReplicatedVmDatetime(b) ? -1 : 1)
export const compareReplicatedVmDatetime = (a, b) => (getReplicatedVmDatetime(a) < getReplicatedVmDatetime(b) ? -1 : 1)
/**
*
* @param {Xapi} xapi
* @param {string} scheduleOrJobId
* @param {string} srUuid
* @param {string} vmUuid
* @returns {Array<XoVm>}
*/
export function listReplicatedVms(xapi, scheduleOrJobId, srUuid, vmUuid) {
const { all } = xapi.objects
const all = xapi.objects.indexes.type.VM
const vms = {}
for (const key in all) {
const object = all[key]

View File

@@ -0,0 +1,138 @@
import { WinRM } from 'winrm-js';
interface HyperVDisk {
id: string;
name: string;
virtualSize: number;
path: string;
}
interface HyperVNetwork {
id: string;
label: string;
macAddress: string;
isStatic: boolean;
ipAddresses: string[];
}
interface VMMetadata {
id: string;
name: string;
cpuCount: number;
memoryMB: number;
disks: HyperVDisk[];
networks: HyperVNetwork[];
}
class HyperVManager {
private client: any;
constructor(host: string, user: string, pass: string) {
this.client = new WinRM({
host,
user,
pass,
port: 5985,
ssl: false
});
}
/**
* List basic VM info
*/
async listVMs(): Promise<{ id: string; name: string }[]> {
const script = `
Get-VM | Select-Object @{n='id';e={$_.Id.ToString()}}, Name | ConvertTo-Json
`;
const result = await this.runPowerShell(script);
return JSON.parse(result);
}
/**
* Get detailed metadata for a specific VM
*/
async getVMMetadata(vmId: string): Promise<VMMetadata> {
const script = `
$vm = Get-VM -Id "${vmId}"
$hardDrives = Get-VMHardDiskDrive -VMId $vm.Id
$adapters = Get-VMNetworkAdapter -VMId $vm.Id
$metadata = @{
id = $vm.Id.ToString()
name = $vm.Name
cpuCount = $vm.ProcessorCount
memoryMB = $vm.MemoryAssigned / 1MB
disks = $hardDrives | ForEach-Object {
$vhd = Get-VHD -Path $_.Path
@{
id = $_.ControllerLocation.ToString()
name = $_.Name
virtualSize = $vhd.Size
path = $_.Path
}
}
networks = $adapters | ForEach-Object {
@{
id = $_.Id
label = $_.Name
macAddress = $_.MacAddress
# IP info requires Integration Services to be running
ipAddresses = $_.IPAddresses
isStatic = if ($_.AddressType -eq 'Static') { $true } else { $false }
}
}
}
$metadata | ConvertTo-Json -Depth 5
`;
const result = await this.runPowerShell(script);
return JSON.parse(result);
}
private async runPowerShell(command: string): Promise<string> {
return new Promise((resolve, reject) => {
this.client.sendCommand(command, (err: any, output: string) => {
if (err) return reject(err);
resolve(output);
});
});
}
}
// Example Usage:
/*
(async () => {
const hv = new HyperVManager('192.168.1.10', 'Administrator', 'Password123');
const vms = await hv.listVMs();
console.log('VM List:', vms);
if (vms.length > 0) {
const details = await hv.getVMMetadata(vms[0].id);
console.log('Detailed Metadata:', JSON.stringify(details, null, 2));
}
})();
*/
/*
By default, Windows blocks remote access to administrative shares (C$) for local (non-domain) accounts, even if they are administrators. This is due to Remote UAC.
If your Hyper-V host is not in an Active Directory domain, you must run this PowerShell command on the Hyper-V host once to allow your Node.js tool to connect:
# Run as Administrator on the Hyper-V Host
New-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" `
-Name "LocalAccountTokenFilterPolicy" -Value 1 -PropertyType DWORD -Force
import SMB2 from 'smb2';
const smbClient = new SMB2({
share: '\\\\192.168.1.10\\C$',
domain: 'WORKGROUP',
username: 'Administrator',
password: 'YourPassword'
});
// Read the VHDX as a stream
const stream = smbClient.createReadStream('VMs\\Disk1.vhdx');
*/

View File

@@ -0,0 +1,271 @@
# RandomAccessVhdx Implementation
A TypeScript implementation for reading Microsoft VHDX (Virtual Hard Disk v2) files.
## Features
✅ **Read-only VHDX access** - Safely read virtual disk data
✅ **Random block access** - Read any block in any order
✅ **Differencing disk support** - Handle parent-child disk relationships
✅ **Metadata extraction** - Get virtual disk UUID, parent path, disk parameters
✅ **Efficient iteration** - Stream blocks using async generators
✅ **Parent fallback** - Automatically read from parent when blocks are missing
## Quick Start
```typescript
import { RandomAccessVhdx } from './RandomAccessVhdx.mjs'
import { open } from 'fs/promises'
const fd = await open('disk.vhdx', 'r')
const vhdx = new RandomAccessVhdx(fd)
await vhdx.init()
// Get disk info
console.log('Size:', vhdx.getVirtualSize())
console.log('UUID:', vhdx.getVirtualDiskId())
// Read a specific block
const block = await vhdx.readBlock(0)
console.log('Block 0 data:', block.data)
// Iterate all blocks
for await (const block of vhdx.diskBlocks()) {
// Process each block
}
await fd.close()
```
## API Reference
### Constructor
```typescript
new RandomAccessVhdx(fd: FileHandle)
```
- **fd**: File handle opened in read mode (`'r'`)
### Methods
#### `async init(): Promise<void>`
Initialize the VHDX reader. Must be called before using other methods.
- Reads file identifier, headers, region table
- Parses metadata (block size, virtual size, UUIDs)
- Loads the Block Allocation Table (BAT)
#### `getVirtualSize(): number`
Returns the virtual disk size in bytes.
#### `getBlockSize(): number`
Returns the block size in bytes (typically 2MB or 32MB).
#### `isDifferencing(): boolean`
Returns true if this is a differencing disk (has a parent).
#### `getVirtualDiskId(): string | undefined`
Returns the virtual disk UUID as a hex string.
#### `getParentPath(): string | undefined`
Returns the parent disk path if this is a differencing disk.
#### `getBlockIndexes(): Array<number>`
Returns array of block indexes that are present in this disk.
#### `hasBlock(index: number): boolean`
Check if a specific block is present in this disk (without checking parent).
#### `async readBlock(index: number): Promise<DiskBlock>`
Read a specific block by index.
- Returns block data from this disk if present
- Falls back to parent disk if this is a differencing disk
- Returns zeroed block if not present anywhere
#### `async *diskBlocks(): AsyncGenerator<DiskBlock>`
Async generator that yields all blocks in the disk.
### Properties
#### `parent: RandomAccessDisk | undefined`
Reference to the parent disk (if opened).
## VHDX File Format
The implementation handles the following VHDX structures:
### File Layout
```
Offset Size Content
0x00000000 64KB File Identifier
0x00010000 64KB Header 1
0x00020000 64KB Header 2
0x00030000 64KB Region Table 1
0x00040000 64KB Region Table 2
Variable Var Log Entries
Variable Var BAT (Block Allocation Table)
Variable Var Metadata Region
Variable Var Data Blocks
```
### Key Structures
**File Identifier** (64KB)
- Signature: "vhdxfile"
- Creator application
- VHDX format version
**Headers** (2 copies, 64KB each)
- Sequence number (used to determine current header)
- File/Data write GUIDs
- Log information
**Region Table**
- Locates BAT and Metadata regions
- Uses GUIDs to identify regions
**Metadata Region**
- File Parameters (block size, parent flag)
- Virtual Disk Size
- Virtual Disk ID
- Logical/Physical Sector Size
- Parent Locator (for differencing disks)
**BAT (Block Allocation Table)**
- Array of 64-bit entries
- Each entry contains:
- State (3 bits): Block allocation state
- File offset (44 bits): Location in file
- States:
- `0`: NOT_PRESENT
- `2`: ZERO
- `3`: UNMAPPED
- `6`: PAYLOAD_BLOCK_FULLY_PRESENT (the only state with actual data)
### Block States
| State | Name | Meaning |
|-------|------------------------------|-----------------------------------|
| 0 | NOT_PRESENT | Block not allocated |
| 2 | ZERO | Block is all zeros |
| 3 | UNMAPPED | Block not mapped |
| 6 | PAYLOAD_BLOCK_FULLY_PRESENT | Block contains data |
| 7 | PAYLOAD_BLOCK_PARTIALLY_PRESENT | Block partially allocated |
## Differencing Disks
VHDX supports differencing (child) disks that reference a parent disk:
```typescript
const childFd = await open('child.vhdx', 'r')
const child = new RandomAccessVhdx(childFd)
await child.init()
if (child.isDifferencing()) {
console.log('Parent path:', child.getParentPath())
// Open parent (requires implementing instantiateParent)
const parent = await child.openParent()
// Reading blocks automatically falls back to parent
const block = await child.readBlock(10)
// Returns data from child if present, otherwise from parent
}
```
### Parent Locator
The parent locator metadata contains:
- `relative_path`: Path relative to child disk
- `absolute_win32_path`: Absolute Windows path
- `volume_path`: Volume-relative path
## Implementation Notes
### What's Implemented
- ✅ File identifier validation
- ✅ Dual header support (uses header with highest sequence number)
- ✅ Region table parsing
- ✅ Metadata parsing (all standard metadata items)
- ✅ BAT reading and caching
- ✅ Block reading with proper offset calculation
- ✅ Parent disk reference extraction
- ✅ Differencing disk support (parent fallback)
### What's Not Implemented
- ❌ Write operations (read-only by design)
- ❌ Log replay (assumes clean disk state)
- ❌ Checksum validation
- ❌ Parent disk auto-instantiation (requires external implementation)
- ❌ Sector bitmap blocks (assumes full blocks)
- ❌ Encryption support
### Performance Considerations
1. **BAT Caching**: The entire BAT is loaded into memory on init
- For large disks (1TB+), this uses significant memory (~100MB per 1TB)
- Trade-off for fast block lookups
2. **Block Reading**: Each block is read synchronously
- Consider implementing block prefetching for sequential access
- Use the async generator for streaming large disks
3. **Parent Fallback**: Reading from parent requires opening parent file
- Keep parent handles open for better performance
- Close properly to avoid resource leaks
## Error Handling
The implementation throws errors for:
- Invalid file signature
- Corrupted headers
- Missing required regions (BAT, Metadata)
- Out of range block indexes
- Unopened parent disks
Always wrap in try-catch:
```typescript
try {
const vhdx = new RandomAccessVhdx(fd)
await vhdx.init()
// ... use vhdx
} catch (error) {
console.error('VHDX error:', error)
} finally {
await fd.close()
}
```
## Testing
To test the implementation:
```typescript
// Test basic reading
const vhdx = new RandomAccessVhdx(fd)
await vhdx.init()
assert(vhdx.getVirtualSize() > 0)
assert(vhdx.getBlockSize() > 0)
// Test block reading
const blocks = vhdx.getBlockIndexes()
for (const index of blocks) {
const block = await vhdx.readBlock(index)
assert(block.data.length === vhdx.getBlockSize())
}
// Test differencing disk
if (vhdx.isDifferencing()) {
assert(vhdx.getParentPath() !== undefined)
}
```
## References
- [VHDX Format Specification (MS-VHDX)](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-vhdx/)
- [Virtual Hard Disk v2 Overview](https://docs.microsoft.com/en-us/previous-versions/windows/desktop/legacy/hh850033(v=vs.85))
## License
This implementation is based on the public VHDX specification from Microsoft.

View File

@@ -0,0 +1,408 @@
// code generated by claude
import { RandomAccessDisk, DiskBlock } from '@xen-orchestra/disk-transform'
import type { FileHandle } from 'fs/promises'
// VHDX Constants
const VHDX_SIGNATURE = Buffer.from('vhdxfile')
const VHDX_HEADER_SIGNATURE = Buffer.from('head')
const VHDX_REGION_TABLE_SIGNATURE = Buffer.from('regi')
const VHDX_LOG_SIGNATURE = Buffer.from('loge')
const VHDX_METADATA_SIGNATURE = Buffer.from('metadata')
const VHDX_BAT_SIGNATURE = Buffer.from('bat\0')
const VHDX_PARENT_LOCATOR_SIGNATURE = Buffer.from('parent_linkage')
const KB = 1024
const MB = 1024 * KB
const VHDX_HEADER_SIZE = 64 * KB
const VHDX_REGION_TABLE_SIZE = 64 * KB
// VHDX GUIDs
const BAT_GUID = '2dc27766f62342009d64115e9bfd4a08'
const METADATA_GUID = '8b7ca20647904b9aba8fe8289ed58fff'
// Metadata item GUIDs
const FILE_PARAMETERS_GUID = 'caa16737fa364d43b3b633f0aa44e76b'
const VIRTUAL_DISK_SIZE_GUID = '2fa54224cd1b4876b211745d45bb85a4'
const VIRTUAL_DISK_ID_GUID = 'beca12abffe9384fb77261e8ee712f16'
const LOGICAL_SECTOR_SIZE_GUID = '8141bf1dace94aa0a7e1f05a7b8c4f40'
const PHYSICAL_SECTOR_SIZE_GUID = 'cda348c7445d44718b9dce6949d09c0b'
const PARENT_LOCATOR_GUID = 'a8d35f2db30b454d89acda70a3c8c9a3'
interface VhdxHeader {
signature: Buffer
checksum: number
sequenceNumber: bigint
fileWriteGuid: Buffer
dataWriteGuid: Buffer
logGuid: Buffer
logVersion: number
version: number
logLength: number
logOffset: bigint
}
interface VhdxRegionTableEntry {
guid: Buffer
fileOffset: bigint
length: number
required: boolean
}
interface VhdxMetadataTableEntry {
itemId: Buffer
offset: number
length: number
isUser: boolean
isVirtualDisk: boolean
isRequired: boolean
}
interface VhdxFileParameters {
blockSize: number
leaveBlocksAllocated: boolean
hasParent: boolean
}
interface BatEntry {
state: number
reserved: number
fileOffsetMB: bigint
}
export class RandomAccessVhdx extends RandomAccessDisk {
#fd: FileHandle
#header?: VhdxHeader
#batOffset: bigint = 0n
#batLength: number = 0
#metadataOffset: bigint = 0n
#metadataLength: number = 0
#blockSize: number = 0
#virtualSize: bigint = 0n
#virtualDiskId?: Buffer
#hasParent: boolean = false
#parentLocatorEntries: any[] = []
#parentPath?: string
#chunkRatio: number = 0
#totalBatEntries: number = 0
#logicalSectorSize: number = 512
#batCache?: BatEntry[]
constructor(fd: FileHandle) {
super()
this.#fd = fd
}
async init(): Promise<void> {
// Read and validate file identifier
await this.#readFileIdentifier()
// Read headers (there are 2, we need to find the current one)
this.#header = await this.#readCurrentHeader()
// Read region table to find BAT and Metadata
await this.#readRegionTable()
// Read metadata
await this.#readMetadata()
// Calculate derived values
this.#calculateDerivedValues()
// Read BAT (Block Allocation Table)
await this.#readBAT()
}
async close(): Promise<void> {
// No need to close fd here, it's managed externally
}
getVirtualSize(): number {
return Number(this.#virtualSize)
}
getBlockSize(): number {
return this.#blockSize
}
isDifferencing(): boolean {
return this.#hasParent
}
instantiateParent(): RandomAccessDisk {
if (!this.#hasParent || !this.#parentPath) {
throw new Error('This VHDX does not have a parent')
}
// This would need to be implemented based on how you want to handle parent disk instantiation
throw new Error('Parent instantiation not implemented - requires path to parent disk')
}
getVirtualDiskId(): string | undefined {
return this.#virtualDiskId?.toString('hex')
}
getParentPath(): string | undefined {
return this.#parentPath
}
getBlockIndexes(): Array<number> {
const indexes: number[] = []
if (!this.#batCache) {
return indexes
}
for (let i = 0; i < this.#batCache.length; i++) {
const entry = this.#batCache[i]
if (entry.state === 6) { // PAYLOAD_BLOCK_FULLY_PRESENT
indexes.push(i)
}
}
return indexes
}
hasBlock(index: number): boolean {
if (!this.#batCache || index >= this.#batCache.length) {
return false
}
const entry = this.#batCache[index]
return entry.state === 6 // PAYLOAD_BLOCK_FULLY_PRESENT
}
async readBlock(index: number): Promise<DiskBlock> {
if (!this.#batCache) {
throw new Error('BAT not loaded')
}
if (index >= this.#batCache.length) {
throw new Error(`Block index ${index} out of range`)
}
const entry = this.#batCache[index]
// If block is not present in this disk, try parent
if (entry.state !== 6) { // Not PAYLOAD_BLOCK_FULLY_PRESENT
if (this.#hasParent && this.parent) {
return this.parent.readBlock(index)
}
// Return zeroed block
return {
index,
data: Buffer.alloc(this.#blockSize)
}
}
// Read block data from file
const fileOffset = entry.fileOffsetMB * BigInt(MB)
const data = Buffer.alloc(this.#blockSize)
await this.#fd.read(data, 0, this.#blockSize, Number(fileOffset))
return {
index,
data
}
}
// Private methods for reading VHDX structures
async #readFileIdentifier(): Promise<void> {
const buffer = Buffer.alloc(64 * KB)
await this.#fd.read(buffer, 0, 64 * KB, 0)
const signature = buffer.subarray(0, 8)
if (!signature.equals(VHDX_SIGNATURE)) {
throw new Error('Invalid VHDX file signature')
}
}
async #readCurrentHeader(): Promise<VhdxHeader> {
// VHDX has two headers at 64KB and 128KB, use the one with highest sequence number
const header1 = await this.#readHeader(64 * KB)
const header2 = await this.#readHeader(128 * KB)
return header1.sequenceNumber > header2.sequenceNumber ? header1 : header2
}
async #readHeader(offset: number): Promise<VhdxHeader> {
const buffer = Buffer.alloc(4 * KB)
await this.#fd.read(buffer, 0, 4 * KB, offset)
const signature = buffer.subarray(0, 4)
if (!signature.equals(VHDX_HEADER_SIGNATURE)) {
throw new Error('Invalid VHDX header signature')
}
return {
signature,
checksum: buffer.readUInt32LE(4),
sequenceNumber: buffer.readBigUInt64LE(8),
fileWriteGuid: buffer.subarray(16, 32),
dataWriteGuid: buffer.subarray(32, 48),
logGuid: buffer.subarray(48, 64),
logVersion: buffer.readUInt16LE(64),
version: buffer.readUInt16LE(66),
logLength: buffer.readUInt32LE(68),
logOffset: buffer.readBigUInt64LE(72)
}
}
async #readRegionTable(): Promise<void> {
const buffer = Buffer.alloc(64 * KB)
await this.#fd.read(buffer, 0, 64 * KB, 192 * KB) // Region table starts at 192KB
const signature = buffer.subarray(0, 4)
if (!signature.equals(VHDX_REGION_TABLE_SIGNATURE)) {
throw new Error('Invalid VHDX region table signature')
}
const entryCount = buffer.readUInt32LE(8)
for (let i = 0; i < entryCount; i++) {
const entryOffset = 16 + (i * 32)
const guid = buffer.subarray(entryOffset, entryOffset + 16)
const guidString = this.#formatGuid(guid)
const fileOffset = buffer.readBigUInt64LE(entryOffset + 16)
const length = buffer.readUInt32LE(entryOffset + 24)
const required = (buffer.readUInt32LE(entryOffset + 28) & 1) === 1
if (guidString === BAT_GUID) {
this.#batOffset = fileOffset
this.#batLength = length
} else if (guidString === METADATA_GUID) {
this.#metadataOffset = fileOffset
this.#metadataLength = length
}
}
if (this.#batOffset === 0n || this.#metadataOffset === 0n) {
throw new Error('Could not find BAT or Metadata regions')
}
}
async #readMetadata(): Promise<void> {
const buffer = Buffer.alloc(this.#metadataLength)
await this.#fd.read(buffer, 0, this.#metadataLength, Number(this.#metadataOffset))
const signature = buffer.subarray(0, 8)
if (!signature.equals(VHDX_METADATA_SIGNATURE)) {
throw new Error('Invalid VHDX metadata signature')
}
const entryCount = buffer.readUInt16LE(10)
const entries: VhdxMetadataTableEntry[] = []
for (let i = 0; i < entryCount; i++) {
const entryOffset = 32 + (i * 32)
const itemId = buffer.subarray(entryOffset, entryOffset + 16)
const offset = buffer.readUInt32LE(entryOffset + 16)
const length = buffer.readUInt32LE(entryOffset + 20)
const flags = buffer.readUInt32LE(entryOffset + 24)
entries.push({
itemId,
offset,
length,
isUser: (flags & 1) === 1,
isVirtualDisk: (flags & 2) === 2,
isRequired: (flags & 4) === 4
})
}
// Read metadata items
for (const entry of entries) {
const guidString = this.#formatGuid(entry.itemId)
const itemData = buffer.subarray(entry.offset, entry.offset + entry.length)
switch (guidString) {
case FILE_PARAMETERS_GUID:
this.#readFileParameters(itemData)
break
case VIRTUAL_DISK_SIZE_GUID:
this.#virtualSize = itemData.readBigUInt64LE(0)
break
case VIRTUAL_DISK_ID_GUID:
this.#virtualDiskId = Buffer.from(itemData)
break
case LOGICAL_SECTOR_SIZE_GUID:
this.#logicalSectorSize = itemData.readUInt32LE(0)
break
case PARENT_LOCATOR_GUID:
this.#readParentLocator(itemData)
break
}
}
}
#readFileParameters(data: Buffer): void {
this.#blockSize = data.readUInt32LE(0)
const flags = data.readUInt32LE(4)
this.#hasParent = (flags & 1) === 1
}
#readParentLocator(data: Buffer): void {
const locatorType = data.subarray(0, 16)
const keyValueCount = data.readUInt16LE(18)
let offset = 20
for (let i = 0; i < keyValueCount; i++) {
const keyOffset = data.readUInt32LE(offset)
const valueOffset = data.readUInt32LE(offset + 4)
const keyLength = data.readUInt16LE(offset + 8)
const valueLength = data.readUInt16LE(offset + 10)
const key = data.subarray(keyOffset, keyOffset + keyLength).toString('utf16le').replace(/\0/g, '')
const value = data.subarray(valueOffset, valueOffset + valueLength).toString('utf16le').replace(/\0/g, '')
this.#parentLocatorEntries.push({ key, value })
if (key === 'relative_path' || key === 'absolute_win32_path') {
this.#parentPath = value
}
offset += 12
}
}
#calculateDerivedValues(): void {
// Calculate chunk ratio (2^23 = 8MB / blockSize)
const chunkSize = 8 * MB
this.#chunkRatio = Math.floor(chunkSize / this.#blockSize)
// Calculate total BAT entries
const dataBlocksCount = Number((this.#virtualSize + BigInt(this.#blockSize) - 1n) / BigInt(this.#blockSize))
const sectorBitmapBlocksCount = Math.ceil(dataBlocksCount / this.#chunkRatio)
this.#totalBatEntries = dataBlocksCount + ((dataBlocksCount - 1) / this.#chunkRatio)
}
async #readBAT(): Promise<void> {
const batSizeBytes = Math.ceil(this.#totalBatEntries * 8) // Each entry is 8 bytes
const buffer = Buffer.alloc(batSizeBytes)
await this.#fd.read(buffer, 0, batSizeBytes, Number(this.#batOffset))
this.#batCache = []
for (let i = 0; i < this.#totalBatEntries; i++) {
const offset = i * 8
const entry = buffer.readBigUInt64LE(offset)
const state = Number(entry & 0x7n) // Lower 3 bits
const reserved = Number((entry >> 3n) & 0x1FFFFFFFFFFFFFn) // Next 17 bits
const fileOffsetMB = entry >> 20n // Upper 44 bits
this.#batCache.push({
state,
reserved,
fileOffsetMB
})
}
}
#formatGuid(buffer: Buffer): string {
// Convert GUID from little-endian format to string
const d1 = buffer.readUInt32LE(0).toString(16).padStart(8, '0')
const d2 = buffer.readUInt16LE(4).toString(16).padStart(4, '0')
const d3 = buffer.readUInt16LE(6).toString(16).padStart(4, '0')
const d4 = buffer.subarray(8, 10).toString('hex')
const d5 = buffer.subarray(10, 16).toString('hex')
return `${d1}${d2}${d3}${d4}${d5}`
}
}

View File

@@ -44,6 +44,7 @@
<!--packages-start-->
- @vates/types minor
- @xen-orchestra/backups minor
- @xen-orchestra/fs patch
- @xen-orchestra/rest-api minor
- @xen-orchestra/web patch
@@ -51,6 +52,6 @@
- xo-server-load-balancer minor
- xo-server-netbox minor
- xo-server-openmetrics minor
- xo-web patch
- xo-web minor
<!--packages-end-->

View File

@@ -48,6 +48,14 @@ const SCHEMA_SETTINGS = {
type: 'string',
optional: true,
},
distributeBackups: {
type: 'boolean',
optional: true,
},
distributeReplications: {
type: 'boolean',
optional: true,
},
},
additionalProperties: true,
},

View File

@@ -644,6 +644,12 @@ const messages = {
editJobNotFound: "The job you're trying to edit wasn't found",
preferNbd: 'Use NBD to transfer disk if available',
preferNbdInformation: 'A network accessible by XO or the proxy must have NBD enabled.',
distributeBackups: 'Distribute backups accross backup repositories',
distributeBackupsInformation:
'This will write exactly one backup archive of each VM instead of writing one per backup repository',
distributeReplications: 'Distribute replications accross the storage repositories',
distributeReplicationsInformation:
'This will write exactly one replication of each VM backup instead of writing one per storage repository',
nbdConcurrency: 'Number of NBD connection per disk',
cbtDestroySnapshotData: 'Purge snapshot data when using CBT.',
cbtDestroySnapshotDataInformation:

View File

@@ -16,6 +16,8 @@ const DEFAULTS = {
preferNbd: false,
reportWhen: 'failure',
timeout: 0,
distributeBackups: false,
distributeReplications: false,
}
const MODES = {

View File

@@ -152,6 +152,8 @@ const normalizeSettings = ({ copyMode, exportMode, offlineBackupActive, settings
exportRetention: exportMode ? setting.exportRetention : undefined,
snapshotRetention: snapshotMode && !offlineBackupActive ? setting.snapshotRetention : undefined,
preferNbd: undefined,
distributeBackups: undefined,
distributeReplications: undefined,
}
: setting
)
@@ -201,6 +203,8 @@ const getInitialState = ({ preSelectedVmIds, setHomeVmIdsSelection, suggestedExc
showErrors: false,
smartMode: false,
snapshotMode: false,
distributeBackups: false,
distributeReplications: false,
srs: [],
tags: { notValues: suggestedExcludedTags },
vms: preSelectedVmIds,
@@ -287,7 +291,6 @@ const New = decorate([
}
settings[''].timezone = DEFAULT_TIMEZONE
await createBackupNgJob({
name: state.name,
mode: state.isDelta ? 'delta' : 'full',
@@ -360,6 +363,8 @@ const New = decorate([
exportMode: state.exportMode,
copyMode: state.copyMode,
snapshotMode: state.snapshotMode,
distributeBackups: state.distributeBackups,
distributeReplications: state.distributeReplications,
}).toObject()
if (normalizedSettings[''] === undefined) {
@@ -375,7 +380,6 @@ const New = decorate([
}
normalizedSettings[''].timezone = DEFAULT_TIMEZONE
await editBackupNgJob({
id: props.job.id,
name: state.name,
@@ -709,25 +713,37 @@ const New = decorate([
mergeBackupsSynchronously,
})
},
setDistributeBackups({ setGlobalSettings }, distributeBackups) {
setGlobalSettings({
distributeBackups,
})
},
setDistributeReplications({ setGlobalSettings }, distributeReplications) {
setGlobalSettings({
distributeReplications,
})
},
},
computed: {
compressionId: generateId,
formId: generateId,
inputConcurrencyId: generateId,
inputBackupReportTplId: generateId,
inputCbtDestroySnapshotData: generateId,
inputConcurrencyId: generateId,
inputFullIntervalId: generateId,
inputHideSuccessfulItemsId: generateId,
inputLongTermRetentionDaily: generateId,
inputLongTermRetentionMonthly: generateId,
inputLongTermRetentionWeekly: generateId,
inputLongTermRetentionYearly: generateId,
inputMaxExportRate: generateId,
inputPreferNbd: generateId,
inputMergeBackupsSynchronously: generateId,
inputNbdConcurrency: generateId,
inputNRetriesVmBackupFailures: generateId,
inputBackupReportTplId: generateId,
inputHideSuccessfulItemsId: generateId,
inputMergeBackupsSynchronously: generateId,
inputPreferNbd: generateId,
inputDistributeBackups: generateId,
inputDistributeReplications: generateId,
inputTimeoutId: generateId,
inputLongTermRetentionDaily: generateId,
inputLongTermRetentionWeekly: generateId,
inputLongTermRetentionMonthly: generateId,
inputLongTermRetentionYearly: generateId,
offlineBackupActive: ({ propSettings, settings = propSettings }) =>
Boolean(settings.getIn(['', 'offlineBackup'])),
@@ -832,12 +848,15 @@ const New = decorate([
const { propSettings, settings = propSettings } = state
const compression = defined(state.compression, job.compression, '')
const {
backupReportTpl = 'mjml',
cbtDestroySnapshotData,
checkpointSnapshot,
concurrency,
fullInterval,
hideSuccessfulItems,
longTermRetention = {},
maxExportRate,
mergeBackupsSynchronously,
nbdConcurrency = 1,
nRetriesVmBackupFailures = 0,
offlineBackup,
@@ -845,9 +864,8 @@ const New = decorate([
preferNbd,
reportRecipients,
reportWhen = 'failure',
backupReportTpl = 'mjml',
hideSuccessfulItems,
mergeBackupsSynchronously,
distributeBackups = false,
distributeReplications = false,
timeout,
} = settings.get('') || {}
@@ -986,6 +1004,20 @@ const New = decorate([
</Li>
))}
</Ul>
<label htmlFor={state.inputDistributeBackups}>
<strong>{_('distributeBackups')}</strong>{' '}
<Tooltip content={_('distributeBackupsInformation')}>
<Icon icon='info' />
</Tooltip>
</label>
<Toggle
className='pull-right'
id={state.inputDistributeBackups}
name='distributeBackups'
value={distributeBackups}
disabled={Object.keys(state.remotes).length < 2}
onChange={effects.setDistributeBackups}
/>
</FormGroup>
)}
</CardBlock>
@@ -1032,6 +1064,20 @@ const New = decorate([
</Li>
))}
</Ul>
<label htmlFor={state.srs}>
<strong>{_('distributeReplications')}</strong>{' '}
<Tooltip content={_('distributeReplicationsInformation')}>
<Icon icon='info' />
</Tooltip>
</label>
<Toggle
className='pull-right'
id={state.inputDistributeReplications}
name='distributeReplications'
value={distributeReplications}
disabled={Object.keys(state.remotes).length < 2}
onChange={effects.setDistributeReplications}
/>
</FormGroup>
</CardBlock>
</Card>

View File

@@ -239,6 +239,11 @@ const NewMirrorBackup = decorate([
}),
setMergeBackupsSynchronously: ({ setAdvancedSettings }, mergeBackupsSynchronously) =>
setAdvancedSettings({ mergeBackupsSynchronously }),
setdistributeBackups({ setAdvancedSettings }, distributeBackups) {
setAdvancedSettings({
distributeBackups,
})
},
},
computed: {
vmBackupOptions: async state => {
@@ -280,6 +285,8 @@ const NewMirrorBackup = decorate([
inputHideSuccessfulItemsId: generateId,
inputMirrorAllId: generateId,
inputMergeBackupsSynchronously: generateId,
inputdistributeBackup: generateId,
inputdistributeReplication: generateId,
isBackupInvalid: state =>
state.isMissingName ||
state.isMissingBackupMode ||
@@ -321,6 +328,7 @@ const NewMirrorBackup = decorate([
hideSuccessfulItems,
nRetriesVmBackupFailures = 0,
mergeBackupsSynchronously,
distributeBackups,
} = state.advancedSettings
return (
<form id={state.formId}>
@@ -575,6 +583,20 @@ const NewMirrorBackup = decorate([
</Li>
))}
</Ul>
<label htmlFor={state.inputdistributeBackups}>
<strong>{_('distributeBackups')}</strong>{' '}
<Tooltip content={_('distributeBackupsInformation')}>
<Icon icon='info' />
</Tooltip>
</label>
<Toggle
className='pull-right'
id={state.inputdistributeBackups}
name='setdistributeBackups'
value={distributeBackups}
disabled={state.targetRemoteIds.length < 2}
onChange={effects.setdistributeBackups}
/>
</FormGroup>
</CardBlock>
</Card>

View File

@@ -331,6 +331,8 @@ class JobsTable extends React.Component {
preferNbd,
proxyId,
reportWhen,
distributeBackups,
distributeReplications,
timeout,
} = getSettingsWithNonDefaultValue(job.mode, {
compression: job.compression,
@@ -385,6 +387,8 @@ class JobsTable extends React.Component {
{nRetriesVmBackupFailures > 0 && (
<Li>{_.keyValue(_('nRetriesVmBackupFailures'), nRetriesVmBackupFailures)}</Li>
)}
{distributeBackups && <Li>{_('distributeBackups')}</Li>}
{distributeReplications && <Li>{_('distributeReplications')}</Li>}
</Ul>
)
},