Files
Xen-Orchestra-xen-orchestra…/@xen-orchestra/qa-test/utils/backupUtils.js
Florent BEAUCHAMP c12bd807ed feat(qa-test): add a script to do a load test (#10119)
add new option and script to launch a load test :

    number of vm is configurable
    concurrency is configurable
    number of backup run is configurable

will do multiple run, then a restore
2026-07-22 14:36:50 +02:00

160 lines
6.3 KiB
JavaScript

import { createLogger } from '@xen-orchestra/log'
import assert from 'node:assert'
import { getSyncedHandler } from '@xen-orchestra/fs'
const log = createLogger('xo:qa-test:backup-utils')
/**
* 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) {
const message =
`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.`
log.warn(message, { repositoryName: repository.name, repositoryId: repository.id, count })
throw new Error(message)
}
}
/**
* Asserts that a backup repository's URL matches the URL declared in the .env.
*
* The QA suite identifies repositories by name (e.g. `BACKUP_REPOSITORY_NAME`)
* but it is the URL that determines where data physically lands. If a repository
* with the expected name already exists on the XO server but points at a different
* location, tests would silently operate on the wrong remote. This check fails fast
* so that the operator can either delete the misconfigured remote in XO or update
* the .env to match it.
*
* Passwords are stripped before comparison: XO obfuscates stored remote passwords,
* so comparing credentials would always fail for existing remotes.
*
* @param {{id: string, name: string, url: string}} repository - Repository as returned by the REST API
* @param {string} expectedUrl - Full @xen-orchestra/fs URL expected by the test (from `BACKUP_REPOSITORY_URL`)
* @throws {Error} If `repository.url` does not match `expectedUrl` (ignoring password)
*/
export const assertRepositoryMatchesConfig = (repository, expectedUrl) => {
// Strip password from scheme://user:password@host URLs before comparing.
// XO returns stored URLs with an obfuscated password, so credential comparison
// would always fail for an already-existing remote.
const stripPassword = url => url.replace(/(:\/\/[^:@/]+):([^@]+)@/, '$1:***@')
if (stripPassword(repository.url) !== stripPassword(expectedUrl)) {
throw new Error(
`Backup repository "${repository.name}" (${repository.id}) has url "${repository.url}", ` +
`but the .env requires "${expectedUrl}". ` +
`Delete this remote in XO and re-run, or update the env to match the existing remote.`
)
}
}
// Same find-or-create logic as tests/setup.js, factored out for reuse by load tests.
// Only tracks (for teardown) the repository if this call is the one that created it.
export async function resolveOrCreateBackupRepository(dispatchClient, tracker, { name, url }) {
if (url.startsWith('file://')) {
const { dispose } = await getSyncedHandler({ url })
await dispose()
}
const existing = await dispatchClient.backupRepository.get({ name })
if (existing) {
assertRepositoryMatchesConfig(existing, url)
return existing
}
const id = await dispatchClient.backupRepository.create(name, { url })
const created = await dispatchClient.backupRepository.get({ id })
if (!created) {
throw new Error(`Failed to retrieve created backup repository ${id}`)
}
tracker?.trackResource('backupRepository', id, { name })
log.debug('Created backup repository', { name, id })
return created
}
/**
* Extracts an error message from a backup task's result or error fields.
*
* XO backup logs store errors in various locations depending on the error type:
* - `task.result` as an object with `message` (most common for XAPI errors)
* - `task.result` as a plain string
* - `task.result.code` with optional `task.result.params`
* - `task.error` with a `message` property
*
* @param {Object} task - A backup task object from the log
* @returns {string} Human-readable error message
*/
const extractTaskError = task => {
// Check task.result (most common location for XO backup errors)
if (task.result != null) {
if (typeof task.result === 'string') {
return task.result
}
if (typeof task.result === 'object') {
if (task.result.message) {
const code = task.result.code ? `[${task.result.code}] ` : ''
return `${code}${task.result.message}`
}
if (task.result.code) {
const params = task.result.params ? ` (${task.result.params.join(', ')})` : ''
return `${task.result.code}${params}`
}
}
}
// Fallback to task.error
if (task.error?.message) {
return task.error.message
}
return 'unknown error'
}
/**
* Recursively extracts error messages from backup result tasks.
* @param {Object} result - Backup log result
* @returns {string[]} List of error messages found in failed tasks
*/
export const extractBackupErrors = result => {
const errors = []
const walkTasks = tasks => {
for (const task of tasks ?? []) {
if (task.status === 'failure') {
errors.push(`[${task.message}] ${extractTaskError(task)}`)
}
if (task.tasks) {
walkTasks(task.tasks)
}
}
}
walkTasks(result.tasks)
return errors
}
/**
* Asserts that a backup result has succeeded, with detailed error reporting on failure.
* @param {Object} result - Backup log result
* @param {string} [context] - Additional context for the error message
*/
export const assertBackupSuccess = (result, context = 'Backup') => {
if (result.status !== 'success') {
const errors = extractBackupErrors(result)
const details = errors.length > 0 ? errors.join(' | ') : 'no task-level error details'
log.warn(`${context} failed`, { details, tasks: result.tasks })
assert.strictEqual(result.status, 'success', `${context} should succeed, got '${result.status}': ${details}`)
}
}