test(backups): replication and full mirror backups

* test(replication): test the new snapshot based replication

* fix(tests): use mergeBackupsSynchronously to ensure the backups don't fails with lock already heled error

* fix: ensure the repository that will be purged start empty

* test(backup): add test for full mirror backups
This commit is contained in:
Florent BEAUCHAMP
2026-04-17 11:25:38 +02:00
committed by GitHub
parent 8df8ef0af8
commit 5f7fab9d34
9 changed files with 775 additions and 10 deletions

View File

@@ -22,6 +22,15 @@ BACKUP_REPOSITORY_PATH=/tmp/xo-test-backups
# This path is NOT included in automatic cleanup to protect production data
BACKUP_PATH=/var/lib/xo/backups
# ===========================================
# MIRROR BACKUP TEST CONFIGURATION
# ===========================================
# Destination repository for mirror backup tests
# Source is BACKUP_REPOSITORY_NAME above
MIRROR_DESTINATION_REPOSITORY_NAME=Test mirror QA
MIRROR_DESTINATION_REPOSITORY_PATH=/tmp/xo-test-mirror
# ===========================================
# VHD/XVA EXPORT TEST CONFIGURATION
# ===========================================

View File

@@ -22,6 +22,7 @@ export function backupConfig(name, schedule, vm, backupRepository, options = {})
'': {
timezone: 'Europe/Paris',
exportRetention: 2,
mergeBackupsSynchronously: true,
},
},
vms: {

View File

@@ -272,7 +272,12 @@ export class CleanupClient {
}
}
await this.dispatchClient.backup.deleteBackupJob(job.id)
// Mirror backup jobs require mirrorBackup.deleteJob, standard jobs use backupNg.deleteJob
if (job.type === 'mirrorBackup') {
await this.dispatchClient.backup.deleteMirrorBackupJob(job.id)
} else {
await this.dispatchClient.backup.deleteBackupJob(job.id)
}
}
return await this._deleteResourcesBatch('backup job', findJobs, deleteJob, {

View File

@@ -38,22 +38,31 @@ export class BackupRequest extends AbstractRequest {
// Convert dynamic key format to XO expected format
const convertConfig = cfg => {
const { vms, remotes, ...rest } = cfg
const { vms, remotes, srs, ...rest } = cfg
// Extract first VM UUID
const vmUuid = vms && typeof vms === 'object' ? Object.keys(vms)[0] : undefined
// Extract first Backup Repository ID
const backupRepositoryId = remotes && typeof remotes === 'object' ? Object.keys(remotes)[0] : undefined
return {
const result = {
...rest,
vms: {
id: vmUuid,
},
remotes: {
id: backupRepositoryId,
},
}
// Extract first Backup Repository ID (for backup to remote)
const backupRepositoryId = remotes && typeof remotes === 'object' ? Object.keys(remotes)[0] : undefined
if (backupRepositoryId !== undefined) {
result.remotes = { id: backupRepositoryId }
}
// Extract first SR ID (for CR/DR mode — replication to SR)
const srId = srs && typeof srs === 'object' ? Object.keys(srs)[0] : undefined
if (srId !== undefined) {
result.srs = { id: srId }
}
return result
}
const xoConfig = convertConfig({ ...config, mode: config.mode || 'delta' })
@@ -155,6 +164,139 @@ export class BackupRequest extends AbstractRequest {
}
}
// ===========================================================================
// Mirror Backup operations (mirrorBackup.* API)
// ===========================================================================
/**
* Creates a new mirror backup job in XenOrchestra.
*
* Mirror backup replicates backups from one remote to another.
* Uses `mirrorBackup.createJob` WebSocket API.
*
* @param {Object} config - Mirror backup job configuration
* @param {string} config.mode - 'full' or 'delta'
* @param {string} config.sourceRemote - ID of the source remote
* @param {Object} config.remotes - Destination remotes (e.g., { [remoteId]: {} })
* @param {string} [config.name] - Job name
* @param {Object} [config.schedules] - Schedule definitions
* @param {Object} [config.settings] - Job settings
* @param {Object} [config.filter] - VM filter (by UUID)
* @returns {Promise<string>} The created mirror backup job ID
*/
async createMirrorBackupJob(config) {
this._ensureConnected()
if (!config || typeof config !== 'object') {
throw new Error('Valid mirror backup configuration object is required')
}
const { remotes, ...rest } = config
// Convert remotes to { id: remoteId } format
const remoteId = remotes && typeof remotes === 'object' ? Object.keys(remotes)[0] : undefined
const xoConfig = {
...rest,
mode: config.mode || 'full',
}
if (remoteId !== undefined) {
xoConfig.remotes = { id: remoteId }
}
try {
const result = await this.dispatchClient.xoClient.call('mirrorBackup.createJob', xoConfig)
return result
} catch (error) {
console.error('❌ Mirror backup job creation failed:', {
message: error.message,
code: error.code,
data: error.data,
})
throw error
}
}
/**
* Deletes a mirror backup job.
*
* @param {string} jobId - ID of the mirror backup job to delete
* @returns {Promise<Object>} Deletion result
*/
async deleteMirrorBackupJob(jobId) {
this._ensureConnected()
if (!jobId || typeof jobId !== 'string') {
throw new Error('Valid mirror backup job ID is required')
}
try {
const result = await this.dispatchClient.xoClient.call('mirrorBackup.deleteJob', { id: jobId })
return result
} catch (error) {
console.error('❌ Delete mirror backup job failed:', error.message)
throw error
}
}
/**
* Executes a mirror backup job and monitors completion.
*
* @param {string} jobId - Mirror backup job ID to execute
* @param {string} scheduleId - Schedule ID to use for this execution
* @returns {Promise<Object>} Complete backup log with status and details
*/
async runMirrorJobAndGetLog(jobId, scheduleId) {
this._ensureConnected()
console.log(`🚀 Running mirror backup job ${jobId} with schedule ${scheduleId}...`)
const runStartTime = Date.now() - 5000
try {
await this.dispatchClient.xoClient.call('mirrorBackup.runJob', {
id: jobId,
schedule: scheduleId,
})
} catch (wsError) {
console.error('❌ Run mirror backup job failed:', wsError.message)
throw wsError
}
const backupLog = await waitUntil(
async () => {
try {
const latestLog = await this.dispatchClient.backupLog.getLatestForJob(jobId, runStartTime)
if (!latestLog) {
return false
}
const status = latestLog.status?.toLowerCase()
if (status === 'success' || status === 'failure' || latestLog.end) {
return latestLog
}
return false
} catch (error) {
console.debug('Waiting for mirror backup completion...', error.message)
return false
}
},
2000,
300_000,
{ exponentialBackoff: true, backoffMultiplier: 1.2, maxInterval: 10_000 }
)
console.log(`✅ Mirror backup completed: ${backupLog.status}`)
return backupLog
}
// ===========================================================================
// Standard Backup operations (backupNg.* API)
// ===========================================================================
/**
* Executes a backup job and monitors completion.
*

View File

@@ -30,6 +30,8 @@
"qa:backup:nbd": "node --env-file-if-exists=.env --test tests/backup.nbd.test.js",
"qa:backup:combined": "node --env-file-if-exists=.env --test tests/backup-replication-combined.test.js",
"qa:backup:combined:cycle": "node --env-file-if-exists=.env --test tests/backup-replication-combined.test.js --test-name-pattern='Full Cycle'",
"qa:backup:combined:cr": "node --env-file-if-exists=.env --test tests/backup-replication-combined.test.js --test-name-pattern='CR Mode'",
"qa:mirror": "node --env-file-if-exists=.env --test tests/backup-mirror.test.js",
"qa:export": "node --env-file-if-exists=.env --test tests/export.vhd.test.js",
"qa:export:vhd": "node --env-file-if-exists=.env --test tests/export.vhd.test.js --test-name-pattern='VDI VHD'",
"qa:export:xva": "node --env-file-if-exists=.env --test tests/export.vhd.test.js --test-name-pattern='XVA'",

View File

@@ -0,0 +1,344 @@
import assert from 'node:assert'
import { after, before, describe, it } from 'node:test'
import { backupConfig } from '../backup.config.js'
import {
assertFullOrDelta,
findTaskByMessage,
generateBackupJobName,
getBackupTransferredBytes,
getDefaultSchedule,
getScheduleKey,
} from '../utils/index.js'
import { assertBackupSuccess, assertRepositoryEmpty } from '../utils/backupUtils.js'
import { setup, teardown } from './setup.js'
/** Source backup repository — where initial backups are stored */
const SOURCE_REPOSITORY_NAME = process.env.BACKUP_REPOSITORY_NAME || 'Test backup QA'
/** Destination backup repository — mirror target */
const MIRROR_DESTINATION_REPOSITORY_NAME = process.env.MIRROR_DESTINATION_REPOSITORY_NAME || 'Test mirror QA'
const MIRROR_DESTINATION_REPOSITORY_PATH = process.env.MIRROR_DESTINATION_REPOSITORY_PATH
describe('Mirror Backup - Full Remote', () => {
/** @type {import('../client/dispatchClient.js').DispatchClient} */
let dispatchClient
/** @type {import('../utils/resourceTracker.js').ResourceTracker} */
let tracker
/** @type {{uuid: string, name_label: string, power_state: string}} */
let vm
/** @type {{id: string, name: string}} */
let sourceRepository
/** @type {{id: string, name: string}} */
let destRepository
/**
* Counts backups for a specific VM on a given repository.
* @param {string} repoId - Repository ID
* @param {string} vmUuid - VM UUID to count backups for
* @returns {Promise<number>} Number of backups
*/
const countVmBackups = async (repoId, vmUuid) => {
const backups = await dispatchClient.backup.listVmBackups([repoId])
const repoBackups = backups[repoId] ?? backups[Object.keys(backups)[0]] ?? {}
const vmBackups = repoBackups[vmUuid] ?? []
return vmBackups.length
}
/**
* Purges all VM backup data from one or more repositories.
* @param {...{id: string, name: string}} repos - Repositories to purge
*/
const purgeBackupData = async (...repos) => {
for (const repo of repos) {
if (!repo) {
continue
}
try {
const backups = await dispatchClient.backup.listVmBackups([repo.id])
const ids = []
for (const vmBackups of Object.values(backups)) {
for (const backupList of Object.values(vmBackups)) {
for (const backup of backupList) {
ids.push(backup.id)
}
}
}
if (ids.length > 0) {
console.log(`Purging ${ids.length} backup(s) from ${repo.name}...`)
await dispatchClient.backup.deleteVmBackups(ids)
}
} catch (error) {
console.warn(`Backup purge failed for ${repo.name}: ${error.message}`)
}
}
}
/**
* Creates a backup job, runs it, and returns the result.
* Tracks the job and schedule for cleanup.
*/
const runFullBackup = async () => {
const name = generateBackupJobName()
const schedule = getDefaultSchedule()
const cfg = backupConfig(name, schedule, vm, sourceRepository)
const jobId = await dispatchClient.backup.createBackupJob({ ...cfg, mode: 'full' })
const job = await dispatchClient.backup.details(jobId)
tracker.trackResource('backupJob', jobId, { name, mode: 'full' })
const scheduleKey = getScheduleKey(job)
assert.ok(scheduleKey, 'Backup schedule key is required')
tracker.trackResource('schedule', scheduleKey, { name, backupJobId: jobId })
const result = await dispatchClient.backup.runJobAndGetLog(jobId, scheduleKey)
assertBackupSuccess(result, `Full backup: ${name}`)
return result
}
/**
* Creates a mirror job, runs it, and returns the result.
* Tracks the job and schedule for cleanup.
* @param {Object} [settingsOverride] - Override default mirror settings
*/
const runMirror = async (settingsOverride = {}) => {
const name = generateBackupJobName()
const schedule = getDefaultSchedule()
const config = {
name,
mode: 'full',
sourceRemote: sourceRepository.id,
remotes: { [destRepository.id]: {} },
schedules: { '': schedule },
settings: {
'': {
exportRetention: 3,
mergeBackupsSynchronously: true,
...settingsOverride,
},
},
}
const jobId = await dispatchClient.backup.createMirrorBackupJob(config)
const job = await dispatchClient.backup.details(jobId)
tracker.trackResource('backupJob', jobId, { name, mode: 'full', type: 'mirrorBackup' })
const scheduleKey = getScheduleKey(job)
assert.ok(scheduleKey, 'Mirror schedule key is required')
tracker.trackResource('schedule', scheduleKey, { name, backupJobId: jobId })
const result = await dispatchClient.backup.runMirrorJobAndGetLog(jobId, scheduleKey)
assertBackupSuccess(result, `Mirror backup: ${name}`)
return result
}
before(async () => {
const setupResult = await setup()
dispatchClient = setupResult.dispatchClient
tracker = setupResult.tracker
vm = setupResult.vm
assert.ok(vm, 'Setup should provide a test VM')
console.log(`Using test VM: ${vm.name_label} (${vm.uuid})`)
// Get source backup repository
sourceRepository = await dispatchClient.backupRepository.get({
name: SOURCE_REPOSITORY_NAME,
})
if (!sourceRepository) {
const id = await dispatchClient.backupRepository.create(SOURCE_REPOSITORY_NAME, {
path: process.env.BACKUP_REPOSITORY_PATH || '/tmp/xo-test-backups',
})
sourceRepository = await dispatchClient.backupRepository.get({ id })
if (!sourceRepository) {
throw new Error(`Failed to create source backup repository`)
}
tracker.trackResource('backupRepository', id, { name: SOURCE_REPOSITORY_NAME })
}
console.log(`Source repository: ${sourceRepository.name} (${sourceRepository.id})`)
// Get or create destination backup repository
if (!MIRROR_DESTINATION_REPOSITORY_PATH) {
throw new Error('MIRROR_DESTINATION_REPOSITORY_PATH environment variable is required for mirror backup tests')
}
destRepository = await dispatchClient.backupRepository.get({
name: MIRROR_DESTINATION_REPOSITORY_NAME,
})
if (!destRepository) {
const id = await dispatchClient.backupRepository.create(MIRROR_DESTINATION_REPOSITORY_NAME, {
path: MIRROR_DESTINATION_REPOSITORY_PATH,
})
destRepository = await dispatchClient.backupRepository.get({ id })
if (!destRepository) {
throw new Error(`Failed to create destination backup repository`)
}
tracker.trackResource('backupRepository', id, { name: MIRROR_DESTINATION_REPOSITORY_NAME })
}
console.log(`Destination repository: ${destRepository.name} (${destRepository.id})`)
// Safety check: repositories must be empty to avoid accidental data loss
await assertRepositoryEmpty(dispatchClient, sourceRepository)
await assertRepositoryEmpty(dispatchClient, destRepository)
})
after(async () => {
await purgeBackupData(sourceRepository, destRepository)
if (dispatchClient && tracker) {
await teardown(dispatchClient, tracker)
}
})
// =========================================================================
// Basic Mirror Transfer
// =========================================================================
describe('Basic Mirror Transfer', () => {
after(async () => {
await purgeBackupData(sourceRepository, destRepository)
})
it('should mirror full backups from source to destination', async () => {
console.log('Step 1: Running full backup to populate source repository...')
const backupResult = await runFullBackup()
assertFullOrDelta(backupResult, sourceRepository.id, { mustBeFull: true })
const sourceCount = await countVmBackups(sourceRepository.id, vm.uuid)
assert.ok(sourceCount > 0, 'Source repository should contain at least one backup')
console.log(`Source repository contains ${sourceCount} backup(s)`)
console.log('Step 2: Running full mirror backup (source → destination)...')
const mirrorResult = await runMirror()
const transferTask = findTaskByMessage(mirrorResult, 'transfer')
assert.ok(transferTask, 'Mirror backup should include a transfer task')
assert.strictEqual(transferTask.status, 'success', 'Transfer task should succeed')
const transferredBytes = getBackupTransferredBytes(mirrorResult)
console.log(`Mirror transferred: ${transferredBytes} bytes`)
const destCount = await countVmBackups(destRepository.id, vm.uuid)
assert.ok(destCount > 0, 'Destination repository should contain backups after mirror')
console.log(`Destination repository contains ${destCount} backup(s) after mirror`)
})
it('should only transfer new backups on second mirror run', async () => {
const destCountBefore = await countVmBackups(destRepository.id, vm.uuid)
console.log(`Destination has ${destCountBefore} backup(s) before second mirror`)
console.log('Step 1: Running another full backup to add data on source...')
await runFullBackup()
console.log('Step 2: Running second mirror backup...')
await runMirror()
const destCountAfter = await countVmBackups(destRepository.id, vm.uuid)
console.log(`Destination has ${destCountAfter} backup(s) after second mirror`)
assert.ok(
destCountAfter > destCountBefore,
`Destination should have more backups after second mirror (before: ${destCountBefore}, after: ${destCountAfter})`
)
})
})
// =========================================================================
// Retention — source retention > destination retention
// =========================================================================
describe('Retention: source > destination', () => {
after(async () => {
await purgeBackupData(sourceRepository, destRepository)
})
it('should cap destination backups to destination exportRetention', async () => {
const SOURCE_RETENTION = 4
const DEST_RETENTION = 2
// Populate source with SOURCE_RETENTION full backups
console.log(`Creating ${SOURCE_RETENTION} full backups on source (retention=${SOURCE_RETENTION})...`)
for (let i = 0; i < SOURCE_RETENTION; i++) {
console.log(` Backup ${i + 1}/${SOURCE_RETENTION}...`)
await runFullBackup()
}
const sourceCount = await countVmBackups(sourceRepository.id, vm.uuid)
console.log(`Source has ${sourceCount} backup(s)`)
assert.ok(sourceCount >= SOURCE_RETENTION, `Source should have at least ${SOURCE_RETENTION} backups`)
// Mirror with lower retention on destination
console.log(`Running mirror with destination exportRetention=${DEST_RETENTION}...`)
await runMirror({ exportRetention: DEST_RETENTION })
const destCount = await countVmBackups(destRepository.id, vm.uuid)
console.log(`Destination has ${destCount} backup(s) (expected ≤ ${DEST_RETENTION})`)
assert.ok(
destCount <= DEST_RETENTION,
`Destination should have at most ${DEST_RETENTION} backups (got ${destCount}) ` +
`— source has more backups but destination retention should cap it`
)
assert.ok(destCount > 0, 'Destination should have at least 1 backup')
})
})
// =========================================================================
// Retention — destination retention > source retention
// =========================================================================
describe('Retention: destination > source', () => {
after(async () => {
await purgeBackupData(sourceRepository, destRepository)
})
it('should accumulate backups on destination up to its retention', async () => {
const SOURCE_RETENTION = 2
const DEST_RETENTION = 4
// Create SOURCE_RETENTION backups, mirror, repeat — destination should accumulate
// Round 1: populate source + mirror
console.log(`Round 1: creating ${SOURCE_RETENTION} backups on source...`)
for (let i = 0; i < SOURCE_RETENTION; i++) {
await runFullBackup()
}
console.log(`Round 1: mirroring with destination exportRetention=${DEST_RETENTION}...`)
await runMirror({ exportRetention: DEST_RETENTION })
const destCountRound1 = await countVmBackups(destRepository.id, vm.uuid)
console.log(`Destination has ${destCountRound1} backup(s) after round 1`)
assert.ok(destCountRound1 > 0, 'Destination should have backups after first mirror')
// Round 2: create more backups on source + mirror again
console.log(`Round 2: creating ${SOURCE_RETENTION} more backups on source...`)
for (let i = 0; i < SOURCE_RETENTION; i++) {
await runFullBackup()
}
console.log(`Round 2: mirroring again...`)
await runMirror({ exportRetention: DEST_RETENTION })
const destCountRound2 = await countVmBackups(destRepository.id, vm.uuid)
console.log(`Destination has ${destCountRound2} backup(s) after round 2`)
// Destination should have accumulated more backups than source currently holds
const sourceCount = await countVmBackups(sourceRepository.id, vm.uuid)
console.log(`Source currently has ${sourceCount} backup(s)`)
assert.ok(
destCountRound2 > destCountRound1,
`Destination should accumulate backups across mirror runs ` +
`(round 1: ${destCountRound1}, round 2: ${destCountRound2})`
)
assert.ok(
destCountRound2 <= DEST_RETENTION,
`Destination should not exceed its retention of ${DEST_RETENTION} (got ${destCountRound2})`
)
})
})
})

View File

@@ -4,14 +4,22 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import { backupConfig } from '../backup.config.js'
import { assertFullOrDelta, generateBackupJobName, getDefaultSchedule, getScheduleKey } from '../utils/index.js'
import {
assertFullOrDelta,
assertFullOrDeltaForSr,
findTaskByMessage,
generateBackupJobName,
getBackupTransferredBytes,
getDefaultSchedule,
getScheduleKey,
} from '../utils/index.js'
import {
generateExportFileName,
validateVhdIntegrity,
validateXvaIntegrity,
formatBytes,
} from '../utils/exportUtils.js'
import { assertBackupSuccess } from '../utils/backupUtils.js'
import { assertBackupSuccess, assertRepositoryEmpty } from '../utils/backupUtils.js'
import { setup, teardown } from './setup.js'
/** Default backup repository name used across all tests */
@@ -125,6 +133,9 @@ describe('Backup + Replication Combined Tests', () => {
})
}
// Safety check: repository must be empty to avoid accidental data loss
await assertRepositoryEmpty(dispatchClient, backupRepository)
// Get SR for health checks and restorations
const srId = process.env.SR_ID
if (!srId) {
@@ -443,6 +454,123 @@ describe('Backup + Replication Combined Tests', () => {
})
})
// =========================================================================
// CR Mode - Delta Replication to SR (target VM reuse + snapshots)
// =========================================================================
describe('CR Mode - Delta Replication to SR', () => {
it('should replicate VM to SR, reuse target VM on second run, and create snapshots', async () => {
const targetSrUuid = sr.uuid
// Create CR mode backup job: delta mode + srs (no remotes)
const name = generateBackupJobName()
const schedule = getDefaultSchedule()
const config = {
name,
mode: 'delta',
schedules: { '': schedule },
settings: { '': { timezone: 'Europe/Paris', copyRetention: 3, mergeBackupsSynchronously: true } },
vms: { [vm.uuid]: vm },
srs: { [targetSrUuid]: true },
}
const jobId = await dispatchClient.backup.createBackupJob(config)
const job = await dispatchClient.backup.details(jobId)
assert.strictEqual(job.mode, 'delta', 'CR mode job should be delta')
tracker.trackResource('backupJob', jobId, { name, mode: 'delta' })
const scheduleKey = getScheduleKey(job)
assert.ok(scheduleKey, 'Schedule key is required')
tracker.trackResource('schedule', scheduleKey, { name, backupJobId: jobId })
// Capture VM list before first replication to detect the new target VM
const vmsBefore = await dispatchClient.vm.list()
const vmUuidsBefore = new Set(vmsBefore.map(v => v.uuid))
// --- First replication (full — no existing target) ---
console.log('Running first CR replication (expected full)...')
const result1 = await dispatchClient.backup.runJobAndGetLog(jobId, scheduleKey)
assertBackupSuccess(result1, 'First CR replication')
assertFullOrDeltaForSr(result1, targetSrUuid, { mustBeFull: true })
// Verify "target snapshot" task exists in the log
const snapshotTask1 = findTaskByMessage(result1, 'target snapshot')
assert.ok(snapshotTask1, 'First replication should include a "target snapshot" task')
assert.strictEqual(snapshotTask1.status, 'success', 'Target snapshot task should succeed')
// Find the newly created target VM
const vmsAfterFirst = await dispatchClient.vm.list()
const newVms = vmsAfterFirst.filter(v => !vmUuidsBefore.has(v.uuid))
assert.strictEqual(newVms.length, 1, 'First replication should create exactly one new target VM')
const targetVmUuid = newVms[0].uuid
restoredVmUuids.push(targetVmUuid)
console.log(`Target VM created: ${newVms[0].name_label} (${targetVmUuid})`)
// Check target VM has at least one snapshot
const targetVmAfterFirst = await dispatchClient.vm.details(targetVmUuid)
assert.ok(targetVmAfterFirst, 'Target VM should exist')
const snapshotsAfterFirst = targetVmAfterFirst.snapshots?.length ?? 0
assert.ok(
snapshotsAfterFirst >= 1,
`Target VM should have ≥1 snapshot after first replication, got ${snapshotsAfterFirst}`
)
console.log(`Target VM has ${snapshotsAfterFirst} snapshot(s) after first replication`)
// Record first transfer size for efficiency comparison
const firstTransferSize = getBackupTransferredBytes(result1)
console.log(`First replication transferred: ${firstTransferSize} bytes`)
// --- Second replication (delta — should reuse target VM) ---
console.log('Running second CR replication (expected delta)...')
const result2 = await dispatchClient.backup.runJobAndGetLog(jobId, scheduleKey)
assertBackupSuccess(result2, 'Second CR replication')
assertFullOrDeltaForSr(result2, targetSrUuid, { mustBeFull: false })
// Verify "target snapshot" task in second run
const snapshotTask2 = findTaskByMessage(result2, 'target snapshot')
assert.ok(snapshotTask2, 'Second replication should include a "target snapshot" task')
assert.strictEqual(snapshotTask2.status, 'success', 'Target snapshot task should succeed on second run')
// Verify NO new VMs were created (target VM was reused)
const vmsAfterSecond = await dispatchClient.vm.list()
const newVmsAfterSecond = vmsAfterSecond.filter(v => !vmUuidsBefore.has(v.uuid))
assert.strictEqual(
newVmsAfterSecond.length,
1,
`Second replication should NOT create a new VM — expected 1 new VM total, got ${newVmsAfterSecond.length}`
)
// Target VM should now have more snapshots
const targetVmAfterSecond = await dispatchClient.vm.details(targetVmUuid)
const snapshotsAfterSecond = targetVmAfterSecond.snapshots?.length ?? 0
assert.ok(
snapshotsAfterSecond > snapshotsAfterFirst,
`Target VM should have more snapshots after second replication ` +
`(before: ${snapshotsAfterFirst}, after: ${snapshotsAfterSecond})`
)
console.log(`Target VM has ${snapshotsAfterSecond} snapshot(s) after second replication`)
// Verify delta efficiency: second transfer should be ≤ first
const secondTransferSize = getBackupTransferredBytes(result2)
console.log(`Second replication transferred: ${secondTransferSize} bytes`)
if (firstTransferSize !== null && secondTransferSize !== null) {
assert.ok(
secondTransferSize <= firstTransferSize,
`Delta transfer (${secondTransferSize} bytes) should be ≤ full transfer (${firstTransferSize} bytes)`
)
}
console.log(
`CR mode test passed: target VM reused (${targetVmUuid}), ` +
`snapshots ${snapshotsAfterFirst}→${snapshotsAfterSecond}, ` +
`transfer ${firstTransferSize}→${secondTransferSize} bytes`
)
})
})
// =========================================================================
// State Validation Between Operations
// =========================================================================

View File

@@ -1,5 +1,29 @@
import assert from 'node:assert'
/**
* Asserts that a backup repository is empty (contains no VM backups).
*
* This safety check prevents tests from running against repositories that
* already contain data, which could lead to accidental data loss if the
* repository is misconfigured to point at a production remote.
*
* @param {import('../client/dispatchClient.js').DispatchClient} dispatchClient
* @param {{id: string, name: string}} repository - The backup repository to check
* @throws {Error} If the repository contains existing backups
*/
export const assertRepositoryEmpty = async (dispatchClient, repository) => {
const backups = await dispatchClient.backup.listVmBackups([repository.id])
const count = Object.values(backups).flatMap(vmBackups => Object.values(vmBackups).flat()).length
if (count > 0) {
throw new Error(
`Repository "${repository.name}" (${repository.id}) contains ${count} existing backup(s). ` +
`Tests require empty repositories to avoid accidental data loss. ` +
`Please purge the backups manually or use a dedicated test repository.`
)
}
}
/**
* Extracts an error message from a backup task's result or error fields.
*

View File

@@ -196,6 +196,116 @@ export function getIsFullForBackupRepository(logEntry, backupRepositoryUuid) {
return undefined // Export operation not found for this backup repository
}
// =============================================================================
// SR REPLICATION (CR/DR MODE) UTILITIES
// =============================================================================
/**
* Determines whether a replication was full or incremental for a specific SR.
*
* Similar to getIsFullForBackupRepository but checks for SR-type exports
* (used in CR mode — delta replication to SR).
*
* @param {Object} logEntry - Backup log entry object from XenOrchestra
* @param {string} srUuid - UUID of the target SR
* @returns {boolean|undefined} True if full, false if incremental, undefined if not found
*/
export function getIsFullForSr(logEntry, srUuid) {
if (!logEntry || typeof logEntry !== 'object') {
throw new Error('Valid log entry object is required')
}
assertNonEmptyString(srUuid, 'Valid SR UUID string is required')
function searchTasks(tasks) {
if (!Array.isArray(tasks)) {
return undefined
}
for (const task of tasks) {
if (
task.message === 'export' &&
task.data &&
task.data.id === srUuid &&
task.data.type === 'SR' &&
typeof task.data.isFull === 'boolean'
) {
return task.data.isFull
}
if (task.tasks && task.tasks.length > 0) {
const result = searchTasks(task.tasks)
if (result !== undefined) {
return result
}
}
}
return undefined
}
if (logEntry.tasks && logEntry.tasks.length > 0) {
const result = searchTasks(logEntry.tasks)
if (result !== undefined) {
return result
}
}
return undefined
}
/**
* Asserts replication type (full or delta) for a given target SR.
*
* @param {Object} result - Backup log result
* @param {string} srUuid - UUID of the target SR
* @param {Object} options
* @param {boolean} options.mustBeFull - Whether replication should be full
* @throws {Error} If replication type does not match expectation
*/
export function assertFullOrDeltaForSr(result, srUuid, { mustBeFull }) {
const isFull = getIsFullForSr(result, srUuid)
assert.strictEqual(
isFull,
mustBeFull,
`SR replication should be ${mustBeFull ? 'full' : 'delta'}, got ${isFull === undefined ? 'not found' : isFull ? 'full' : 'delta'}`
)
}
/**
* Finds the first task matching a given message name in a backup log entry.
*
* Recursively searches through all tasks and subtasks.
*
* @param {Object} logEntry - Backup log entry from XenOrchestra
* @param {string} message - Task message to search for (e.g., 'target snapshot', 'transfer')
* @returns {Object|null} The matching task or null if not found
*/
export function findTaskByMessage(logEntry, message) {
function search(tasks) {
if (!Array.isArray(tasks)) {
return null
}
for (const task of tasks) {
if (task.message === message) {
return task
}
if (task.tasks && task.tasks.length > 0) {
const result = search(task.tasks)
if (result) {
return result
}
}
}
return null
}
return search(logEntry.tasks || [])
}
// =============================================================================
// BACKUP ASSERTION UTILITIES
// =============================================================================