mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
12 Commits
1667ed2a16
...
feat_hyper
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
786e05e775 | ||
|
|
6cce923cd6 | ||
|
|
50cc40dd62 | ||
|
|
1ea6bfef54 | ||
|
|
3396c5cad7 | ||
|
|
04c1af5665 | ||
|
|
112ac2f9d0 | ||
|
|
5c7fee5029 | ||
|
|
9b51fc6eb7 | ||
|
|
5e076a56b3 | ||
|
|
af26b7568c | ||
|
|
83019c39bd |
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
138
@xen-orchestra/hyperv-explorer/index.tsx
Normal file
138
@xen-orchestra/hyperv-explorer/index.tsx
Normal 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');
|
||||
|
||||
*/
|
||||
271
@xen-orchestra/vhdx/README.md
Normal file
271
@xen-orchestra/vhdx/README.md
Normal 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.
|
||||
408
@xen-orchestra/vhdx/index.tsx
Normal file
408
@xen-orchestra/vhdx/index.tsx
Normal 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}`
|
||||
}
|
||||
}
|
||||
@@ -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-->
|
||||
|
||||
@@ -48,6 +48,14 @@ const SCHEMA_SETTINGS = {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
distributeBackups: {
|
||||
type: 'boolean',
|
||||
optional: true,
|
||||
},
|
||||
distributeReplications: {
|
||||
type: 'boolean',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -16,6 +16,8 @@ const DEFAULTS = {
|
||||
preferNbd: false,
|
||||
reportWhen: 'failure',
|
||||
timeout: 0,
|
||||
distributeBackups: false,
|
||||
distributeReplications: false,
|
||||
}
|
||||
|
||||
const MODES = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user