Compare commits

...

3 Commits

Author SHA1 Message Date
Thomas Moraine
398ea36537 docs(hosts): link the XCP-ng docs on removing a host from a pool 2026-11-04 19:00:17 +01:00
Thomas Moraine
c91194c374 docs(hosts): explain how to forget a host that is permanently gone
Document when to forget rather than detach a host, the prerequisites
(host powered off, new master if the dead host was the master, clearing
resident VMs to avoid HOST_HAS_RESIDENT_VMS), how to forget it from XO 5,
XO 6 and xe, and what to clean up afterwards.

Link this procedure from the XO 6 management page instead of duplicating it.
2026-11-04 10:28:49 +01:00
spacotte-vates
5601a64703 feat(complex-matcher): use complex matcher for backup jobs (#10064)
Use complex matcher for healtcheck and full backup interval in backup jobs

(XO-2435)
2026-11-02 17:49:16 +01:00
38 changed files with 1087 additions and 90 deletions

View File

@@ -11,6 +11,10 @@
"./xo": "./dist/xo.d.mts",
"./xo-app": "./dist/xo-app.d.mts",
"./xen-api": "./dist/xen-api.d.mts",
"./backup-expression-context": {
"default": "./dist/backup-expression-context.mjs",
"types": "./dist/backup-expression-context.d.mts"
},
"./common": {
"default": "./dist/common.mjs",
"types": "./dist/common.d.mts"

View File

@@ -0,0 +1,38 @@
export const BACKUP_EXPRESSION_CONTEXT = {
run: {
dayOfWeek: { type: 'number' },
dayOfMonth: { type: 'number' },
hour: { type: 'number' },
month: { type: 'number' },
year: { type: 'number' },
},
sr: {
name_label: { type: 'string' },
type: { type: 'string' },
description: { type: 'string' },
tags: { type: 'string[]' },
},
backupRepository: {
name: { type: 'string' },
type: { type: 'string' },
tags: { type: 'string[]' },
},
vm: {
name_label: { type: 'string' },
tags: { type: 'string[]' },
power_state: { type: 'string' },
},
chainLength: { type: 'number' },
} as const
export type BACKUP_EXPRESSION_CONTEXT = (typeof BACKUP_EXPRESSION_CONTEXT)[keyof typeof BACKUP_EXPRESSION_CONTEXT]
export const BACKUP_SETTING_CONTEXT = {
healthCheckVmsWithTags: ['vm', 'run'],
fullInterval: ['vm', 'run', 'chainLength'],
vmFilter: ['vm', 'run'],
srs: ['sr', 'run'],
remotes: ['backupRepository', 'run'],
} as const
export type BACKUP_SETTING_CONTEXT = (typeof BACKUP_SETTING_CONTEXT)[keyof typeof BACKUP_SETTING_CONTEXT]

View File

@@ -552,8 +552,8 @@ export type XoVmBackupJobGeneralSettings = XoBackupJobGeneralSettings & {
}
export type XoVmBackupJobScheduleSettings = {
exportRetention?: number
healthCheckVmsWithTags?: XoVm['tags']
fullInterval?: number
healthCheckVmsWithTags?: XoVm['tags'] | string
fullInterval?: number | string
copyRetention?: number
snapshotRetention?: number
cbtDestroySnapshotData?: boolean
@@ -564,9 +564,9 @@ export type XoVmBackupJob = BaseXoJob & {
compression?: 'native' | 'zstd' | ''
proxy?: XoProxy['id']
mode: 'full' | 'delta'
remotes?: CMType.IdOr<XoBackupRepository['id']>
remotes?: CMType.IdOr<XoBackupRepository['id']> | string
vms: CMType.IdOr<XoVm['id']> | Record<string, unknown>
srs?: CMType.IdOr<XoSr['id']>
srs?: CMType.IdOr<XoSr['id']> | string
type: 'backup'
settings: {
'': XoVmBackupJobGeneralSettings
@@ -598,13 +598,15 @@ export type XoMirrorBackupGeneralSettings = XoBackupJobGeneralSettings & {
nRetriesVmBackupFailures?: number
mergeBackupsSynchronously?: boolean
timeout?: number
timezone?: string
maxExportRate?: number
backupReportTpl?: 'compactMjml'
reportWhen: 'failure'
}
export type XoMirrorBackupScheduleSettings = {
exportRetention?: number
healthCheckVmsWithTags?: XoVm['tags']
healthCheckVmsWithTags?: XoVm['tags'] | string
vmFilter?: string
healthCheckSr?: XoSr['id']
[key: string]: unknown
}

View File

@@ -0,0 +1,111 @@
// @ts-check
import { createLogger } from '@xen-orchestra/log'
import moment from 'moment-timezone'
import { parse } from 'xo-remote-parser'
const { warn } = createLogger('xo:backups:buildContext')
/**
* @typedef {import('@vates/types/xen-api').XenApiVm} XenApiVm
* @typedef {import('@vates/types/xo').XoSr} XoSr
* @typedef {import('@vates/types/xo').XoBackupRepository} XoBackupRepository
*
* @typedef {object} RunContext
* @property {number} dayOfWeek
* @property {number} dayOfMonth
* @property {number} hour
* @property {number} month
* @property {number} year
*
* @typedef {object} VmContext
* @property {Record<string, unknown>} vm
* @property {RunContext} run
* @property {number} [chainLength]
*
* @typedef {object} SrContext
* @property {{name_label: string, type: string, description: string, tags: string[]}} sr
* @property {RunContext} run
*
* @typedef {object} BackupRepositoryContext
* @property {{name: string, type: string | undefined, tags: string[]}} backupRepository
* @property {RunContext} run
*/
/**
* @param {Date} date
* @param {string | undefined} timezone
* @returns {RunContext}
*/
export function buildRunContext(date, timezone) {
const momentDate = timezone ? moment.tz(date, timezone) : moment(date)
return {
dayOfWeek: momentDate.day(),
dayOfMonth: momentDate.date(),
hour: momentDate.hour(),
month: momentDate.month() + 1,
year: momentDate.year(),
}
}
/**
* @param {XenApiVm} vm
* @param {RunContext} run
* @param {number} [chainLength]
* @returns {VmContext}
*/
export function buildVmContext(vm, run, chainLength) {
/** @type {Record<string, unknown>} */
const vmObject = {}
for (const [key, value] of Object.entries(vm)) {
if (!key.startsWith('$') && typeof value !== 'function') {
vmObject[key] = value
}
}
if (!Array.isArray(vmObject.tags)) {
vmObject.tags = []
}
return { vm: vmObject, run, chainLength }
}
/**
* @param {XoSr} sr
* @param {RunContext} run
* @returns {SrContext}
*/
export function buildSrContext(sr, run) {
return {
sr: {
name_label: sr.name_label,
type: sr.SR_type,
description: sr.name_description,
tags: Array.isArray(sr.tags) ? sr.tags : [],
},
run,
}
}
/**
* @param {XoBackupRepository} backupRepository
* @param {RunContext} run
* @returns {BackupRepositoryContext}
*/
export function buildBackupRepositoryContext(backupRepository, run) {
let type
try {
type = parse(backupRepository.url).type
} catch (error) {
warn('cannot parse backup repository URL', { backupRepositoryId: backupRepository.id, error })
}
return {
backupRepository: {
name: backupRepository.name,
type,
tags: [], // Placeholder - Backup repositories don't have tags yet
},
run,
}
}

View File

@@ -0,0 +1,147 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { buildRunContext, buildVmContext, buildSrContext, buildBackupRepositoryContext } from './_buildContext.mjs'
import { BACKUP_EXPRESSION_CONTEXT } from '@vates/types/backup-expression-context'
describe('buildContext', () => {
const date = new Date('2025-01-05T03:00:00Z')
const runContext = buildRunContext(date, 'America/New_York')
const expectedRunContext = {
dayOfWeek: 6,
dayOfMonth: 4,
hour: 22,
month: 1,
year: 2025,
}
it('buildRunContext', () => {
assert.deepEqual(runContext, expectedRunContext)
})
it('buildVmContext', () => {
const vm1 = { name: 'name', $value: '$value', func: () => {} }
const vmContext1 = buildVmContext(vm1, runContext, 3)
assert.deepEqual(vmContext1, {
vm: {
name: 'name',
tags: [],
},
run: expectedRunContext,
chainLength: 3,
})
const vm2 = { name: 'name', $value: '$value', func: () => {}, tags: ['tag'] }
const vmContext2 = buildVmContext(vm2, runContext, undefined)
assert.deepEqual(vmContext2, {
vm: {
name: 'name',
tags: ['tag'],
},
run: expectedRunContext,
chainLength: undefined,
})
const vm3 = { name: 'name', $value: '$value', func: () => {}, tags: 'tag' }
const vmContext3 = buildVmContext(vm3, runContext, undefined)
assert.deepEqual(vmContext3, {
vm: {
name: 'name',
tags: [],
},
run: expectedRunContext,
chainLength: undefined,
})
})
it('buildVmContext exposes every field advertised by the schema', () => {
// vm is an open context (buildVmContext copies through all real VM fields),
// so we assert the schema's curated fields are a subset of the output, not equality.
const vm = { name_label: 'name', power_state: 'Running', tags: ['tag'], $ref: 'ref', func: () => {} }
const { vm: vmContext } = buildVmContext(vm, runContext)
for (const field of Object.keys(BACKUP_EXPRESSION_CONTEXT.vm)) {
assert.ok(field in vmContext, `vm context is missing schema field: ${field}`)
}
})
it('buildSrContext', () => {
const sr1 = {
name_label: 'name_label',
SR_type: 'type',
name_description: 'description',
ignored: 'ignored',
}
const srContext1 = buildSrContext(sr1, runContext)
assert.deepEqual(srContext1, {
sr: {
name_label: 'name_label',
type: 'type',
description: 'description',
tags: [],
},
run: expectedRunContext,
})
assert.deepEqual(Object.keys(srContext1.sr).sort(), Object.keys(BACKUP_EXPRESSION_CONTEXT.sr).sort())
const sr2 = {
name_label: 'name_label',
SR_type: 'type',
name_description: 'description',
tags: ['tag'],
ignored: 'ignored',
}
const srContext2 = buildSrContext(sr2, runContext)
assert.deepEqual(srContext2, {
sr: {
name_label: 'name_label',
type: 'type',
description: 'description',
tags: ['tag'],
},
run: expectedRunContext,
})
assert.deepEqual(Object.keys(srContext2.sr).sort(), Object.keys(BACKUP_EXPRESSION_CONTEXT.sr).sort())
})
it('buildBackupRepositoryContext', () => {
const backupRepository1 = {
name: 'name',
url: 'nfs://host/path',
ignored: 'ignored',
}
const backupRepositoryContext1 = buildBackupRepositoryContext(backupRepository1, runContext)
assert.deepEqual(backupRepositoryContext1, {
backupRepository: {
name: 'name',
type: 'nfs',
tags: [],
},
run: expectedRunContext,
})
assert.deepEqual(
Object.keys(backupRepositoryContext1.backupRepository).sort(),
Object.keys(BACKUP_EXPRESSION_CONTEXT.backupRepository).sort()
)
const backupRepository2 = {
name: 'name',
url: 'nfs://host/path',
tags: [],
ignored: 'ignored',
}
const backupRepositoryContext2 = buildBackupRepositoryContext(backupRepository2, runContext)
assert.deepEqual(backupRepositoryContext2, {
backupRepository: {
name: 'name',
type: 'nfs',
tags: [],
},
run: expectedRunContext,
})
assert.deepEqual(
Object.keys(backupRepositoryContext2.backupRepository).sort(),
Object.keys(BACKUP_EXPRESSION_CONTEXT.backupRepository).sort()
)
})
})

View File

@@ -0,0 +1,24 @@
// @ts-check
import * as CM from 'complex-matcher'
/**
* Parse and compile a complex-matcher expression into a predicate function.
* Throws synchronously if the expression is syntactically invalid.
*
* @param {string} expression
* @returns {(context: object) => boolean}
*/
export function compileExpression(expression) {
return CM.parse(expression).createPredicate()
}
/**
* Returns true if value is a non-empty string — i.e. a complex-matcher expression.
*
* @param {unknown} value
* @returns {value is string}
*/
export function isExpression(value) {
return typeof value === 'string' && value.length > 0 && !Number.isFinite(Number(value))
}

View File

@@ -0,0 +1,95 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { compileExpression, isExpression } from './_expressionPredicate.mjs'
describe('compileExpression', () => {
it('run:dayOfWeek:0', () => {
const predicate = compileExpression('run:dayOfWeek:0')
assert(predicate({ run: { dayOfWeek: 0 } }))
assert(!predicate({ run: { dayOfWeek: 1 } }))
})
it('chainLength:>5', () => {
const predicate = compileExpression('chainLength:>5')
assert(predicate({ chainLength: 6 }))
assert(!predicate({ chainLength: 5 }))
})
it('vm:tags:prod', () => {
const predicate = compileExpression('vm:tags:prod')
assert(predicate({ vm: { tags: ['prod'] } }))
assert(predicate({ vm: { tags: ['prod', 'test'] } }))
assert(!predicate({ vm: { tags: ['test'] } }))
assert(!predicate({ vm: { tags: [] } }))
})
it('run:dayOfWeek:0 chainLength:>3', () => {
const predicate = compileExpression('run:dayOfWeek:0 chainLength:>3')
assert(predicate({ run: { dayOfWeek: 0 }, chainLength: 6 }))
assert(!predicate({ run: { dayOfWeek: 0 }, chainLength: 2 }))
assert(!predicate({ run: { dayOfWeek: 1 }, chainLength: 6 }))
})
it('|(run:dayOfWeek:0 chainLength:>6)', () => {
const predicate = compileExpression('|(run:dayOfWeek:0 chainLength:>6)')
assert(predicate({ run: { dayOfWeek: 0 }, chainLength: 7 }))
assert(predicate({ run: { dayOfWeek: 1 }, chainLength: 7 }))
assert(predicate({ run: { dayOfWeek: 0 }, chainLength: 6 }))
assert(!predicate({ run: { dayOfWeek: 1 }, chainLength: 6 }))
})
it('missing field', () => {
const predicate = compileExpression('run:dayOfWeek:0')
assert(predicate({ run: { dayOfWeek: 0 }, chainLength: 6 }))
assert(!predicate({ chainLength: 6 }))
assert(!predicate({ run: {}, chainLength: 6 }))
})
it('null-checking', () => {
const predicate = compileExpression('!vm')
assert(predicate({}))
assert(!predicate({ vm: [{ vm: 'vm' }] }))
})
it('throws on invalid expression', () => {
assert.throws(() => compileExpression('vm:('))
})
})
describe('isExpression', () => {
it('non-empty string', () => {
assert(isExpression('vm:tags:test'))
})
it('number', () => {
assert(!isExpression(0))
})
it('string[]', () => {
assert(!isExpression(['string']))
})
it('undefined', () => {
assert(!isExpression(undefined))
})
it('empty string', () => {
assert(!isExpression(''))
})
it('number', () => {
assert(!isExpression('3'))
})
it('number with spaces', () => {
assert(!isExpression(' 5 '))
})
})

View File

@@ -10,6 +10,8 @@ import { FullRemote } from './_vmRunners/FullRemote.mjs'
import { IncrementalRemote } from './_vmRunners/IncrementalRemote.mjs'
import { Throttle } from '@vates/generator-toolbox'
import createStreamThrottle from './_createStreamThrottle.mjs'
import { compileExpression, isExpression } from '../_expressionPredicate.mjs'
import { buildRunContext, buildVmContext } from '../_buildContext.mjs'
const noop = Function.prototype
@@ -60,21 +62,25 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
return
}
const runContext = job.runContext ?? buildRunContext(new Date(), settings.timezone)
const predicate = isExpression(settings.vmFilter) ? compileExpression(settings.vmFilter) : null
const vmsUuids = await sourceRemoteAdapter.listAllVms()
const queue = new Set(vmsUuids)
Task.info('vms', { vms: vmsUuids })
const nbVms = vmsUuids.length
let nbVms = vmsUuids.length
let nbVmsDone = 0
remoteAdapters = getAdaptersByRemote(remoteAdapters)
const allSettings = this._job.settings
const baseSettings = this._baseSettings
const queue = new Set(vmsUuids)
const taskByVmId = {}
const nTriesByVmId = {}
const handleVm = vmUuid => {
const handleVm = async vmUuid => {
if (nTriesByVmId[vmUuid] === undefined) {
nTriesByVmId[vmUuid] = 0
}
@@ -84,12 +90,26 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
const vmSettings = { ...settings, ...allSettings[vmUuid] }
const isLastRun = nTriesByVmId[vmUuid] === vmSettings.nRetriesVmBackupFailures + 1
const vmBackups = await sourceRemoteAdapter.listVmBackups(vmUuid, ({ mode }) => mode === job.mode)
// avoiding to create tasks for empty directories
if (vmBackups.length === 0) {
nbVms--
return
}
if (predicate !== null && !predicate(buildVmContext(vmBackups.at(-1).vm, runContext))) {
nbVms--
return
}
const opts = {
baseSettings,
config,
job,
healthCheckSr,
remoteAdapters,
runContext,
schedule,
settings: vmSettings,
sourceRemoteAdapter,
@@ -106,45 +126,39 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
throw new Error(`Job mode ${job.mode} not implemented for mirror backup`)
}
return sourceRemoteAdapter
.listVmBackups(vmUuid, ({ mode }) => mode === job.mode)
.then(vmBackups => {
// avoiding to create tasks for empty directories
if (vmBackups.length > 0) {
if (taskByVmId[vmUuid] === undefined) {
taskByVmId[vmUuid] = new Task(taskStart)
}
const task = taskByVmId[vmUuid]
// error has to be caught in the task to prevent its failure, but handled outside the task to execute another task.run()
let taskError
return task
.runInside(async () =>
vmBackup.run().catch(error => {
taskError = error
})
)
.then(result => {
if (taskError === undefined) {
nbVmsDone++
return task.success(result)
}
if (isLastRun) {
nbVmsDone++
return task.failure(taskError)
}
Task.set('progress', Math.round((nbVmsDone * 100) / nbVms))
// don't end the task
task.warning(`Retry the VM mirror backup due to an error`, {
attempt: nTriesByVmId[vmUuid],
error: taskError.message,
isRetry: true,
})
queue.add(vmUuid)
})
.catch(noop)
if (taskByVmId[vmUuid] === undefined) {
taskByVmId[vmUuid] = new Task(taskStart)
}
const task = taskByVmId[vmUuid]
// error has to be caught in the task to prevent its failure, but handled outside the task to execute another task.run()
let taskError
return task
.runInside(async () =>
vmBackup.run().catch(error => {
taskError = error
})
)
.then(result => {
if (taskError === undefined) {
nbVmsDone++
return task.success(result)
}
if (isLastRun) {
nbVmsDone++
return task.failure(taskError)
}
Task.set('progress', Math.round((nbVmsDone * 100) / nbVms))
// don't end the task
task.warning(`Retry the VM mirror backup due to an error`, {
attempt: nTriesByVmId[vmUuid],
error: taskError.message,
isRetry: true,
})
queue.add(vmUuid)
})
.catch(noop)
}
const { concurrency } = settings
const _handleVm = !concurrency ? handleVm : limitConcurrency(concurrency)(handleVm)

View File

@@ -12,6 +12,7 @@ import { Throttle } from '@vates/generator-toolbox'
import { asyncEach } from '@vates/async-each'
import createStreamThrottle from './_createStreamThrottle.mjs'
import { selectSynchronizedSnapshotVms } from './_selectSynchronizedSnapshotVms.mjs'
import { buildRunContext } from '../_buildContext.mjs'
const noop = Function.prototype
@@ -68,6 +69,7 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
const getSnapshotNameLabel = this._getSnapshotNameLabel
const schedule = this._schedule
const settings = this._settings
const runContext = job.runContext ?? buildRunContext(new Date(), settings.timezone)
const throttleGenerator = new Throttle(settings.maxExportRate)
const throttleStream = createStreamThrottle(settings.maxExportRate)
@@ -151,6 +153,7 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
healthCheckSr,
job,
remoteAdapters,
runContext,
schedule,
settings: vmSettings,
srs,
@@ -249,6 +252,7 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
throttleGenerator,
throttleStream,
vm,
runContext,
}
const vmBackup = this._getVmBackup(job.mode, opts)

View File

@@ -53,7 +53,7 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
'writer.run()'
)
// for healthcheck
this._tags = metadata.vm.tags
this._vm = metadata.vm
nbTransferredVms++
Task.set('progress', Math.round((nbTransferredVms * 100) / nbTransferrableVms))
}

View File

@@ -131,7 +131,7 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
// for healthcheck
this._tags = metadata.vm.tags
this._vm = metadata.vm
nbTransferredVms++
Task.set('progress', Math.round((nbTransferredVms * 100) / nbTransferrableVms))
}

View File

@@ -18,10 +18,19 @@ import { ThrottledDisk, SynchronizedDisk } from '@xen-orchestra/disk-transform'
import { AggregatedIncrementalRemoteWriter } from '../_writers/AggregatedIncrementalRemoteWriter.mjs'
import { AggregatedIncrementalXapiWriter } from '../_writers/AggregatedIncrementalXapiWriter.mjs'
import { TaskProgressHandler } from './_TaskProgressHandler.mjs'
import { compileExpression, isExpression } from '../../_expressionPredicate.mjs'
import { buildVmContext } from '../../_buildContext.mjs'
const { debug } = createLogger('xo:backups:IncrementalXapiVmBackup')
export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends AbstractXapi {
constructor(props) {
super(props)
const fullInterval = this._settings.fullInterval
this._fullIntervalPredicate = isExpression(fullInterval) ? compileExpression(fullInterval) : null
}
_getWriters() {
return [
IncrementalRemoteWriter,
@@ -229,15 +238,17 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
}
// we do tafter checkbasevdis because we want the writer to know the target VM
// especially on replication when we alwayr update the same VM
// especially on replication when we always update the same VM
const fullInterval = this._settings.fullInterval
if (fullInterval !== 0 && fullInterval <= deltaChainLength + 1) {
debug('not using base VM because fullInterval reached', {
fullInterval,
deltaChainLength,
eq: fullInterval < deltaChainLength + 1,
dc1: deltaChainLength + 1,
})
const chainLength = (deltaChainLength ?? 0) + 1
if (isExpression(fullInterval)) {
const context = buildVmContext(this._vm, this._runContext, chainLength)
if (this._fullIntervalPredicate(context)) {
debug('forcing full backup: fullInterval expression matched', { fullInterval })
return
}
} else if (fullInterval !== 0 && fullInterval <= chainLength) {
debug('not using base VM because fullInterval reached', { fullInterval, chainLength })
return
}

View File

@@ -1,6 +1,8 @@
import { asyncMap } from '@xen-orchestra/async-map'
import { createLogger } from '@xen-orchestra/log'
import { Task } from '@vates/task'
import { compileExpression, isExpression } from '../../_expressionPredicate.mjs'
import { buildVmContext } from '../../_buildContext.mjs'
const { debug, warn } = createLogger('xo:backups:AbstractVmRunner')
@@ -73,24 +75,58 @@ export const Abstract = class AbstractVmBackupRunner {
}
// check if current VM has tags
const tags = this._tags
const tags = this._vm.tags
// accept both 'xo:no-health-check' and 'xo:no-health-check=reason'
const vmAlwaysIgnored = tags.some(t => t.startsWith('xo:no-health-check'))
const intersect = !vmAlwaysIgnored && settings.healthCheckVmsWithTags.some(t => tags.includes(t))
if (vmAlwaysIgnored || (settings.healthCheckVmsWithTags.length !== 0 && !intersect)) {
// create a task to have an info in the logs and reports
if (tags.some(t => t.startsWith('xo:no-health-check'))) {
return Task.run(
{
properties: { name: 'health check' },
properties: {
name: 'health check',
},
},
() => {
Task.info(`This VM doesn't match the health check's tags for this schedule`)
Task.info(`This VM is excluded from health checks via tag`)
}
)
}
if (isExpression(settings.healthCheckVmsWithTags)) {
const context = buildVmContext(this._vm, this._runContext)
if (!this._healthCheckPredicate(context)) {
return Task.run(
{
properties: { name: 'health check' },
},
() => {
Task.info(`VM did not match healthCheckVmsWithTags expression`)
}
)
}
} else {
const intersect = settings.healthCheckVmsWithTags.some(t => tags.includes(t))
if (settings.healthCheckVmsWithTags.length !== 0 && !intersect) {
// create a task to have an info in the logs and reports
return Task.run(
{
properties: { name: 'health check' },
},
() => {
Task.info(`This VM doesn't match the health check's tags for this schedule`)
}
)
}
}
await this._callWriters(writer => writer.healthCheck(), 'writer.healthCheck()')
}
_compileHealthCheckPredicate() {
const { healthCheckVmsWithTags } = this._settings
if (isExpression(healthCheckVmsWithTags)) {
this._healthCheckPredicate = compileExpression(healthCheckVmsWithTags)
} else {
this._healthCheckPredicate = undefined
}
}
}

View File

@@ -20,6 +20,7 @@ export const AbstractRemote = class AbstractRemoteVmBackupRunner extends Abstrac
job,
healthCheckSr,
remoteAdapters,
runContext,
schedule,
settings,
sourceRemoteAdapter,
@@ -40,11 +41,14 @@ export const AbstractRemote = class AbstractRemoteVmBackupRunner extends Abstrac
this._throttleGenerator = throttleGenerator
this._throttleStream = throttleStream
this._vmUuid = vmUuid
this._runContext = runContext
const allSettings = job.settings
const writers = new Set()
this._writers = writers
this._compileHealthCheckPredicate()
const [BackupWriter, AggregratedBackupWriter] = this._getRemoteWriters()
if (settings.distributeBackups) {
writers.add(

View File

@@ -36,6 +36,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
job,
remoteAdapters,
remotes,
runContext,
schedule,
settings,
srs,
@@ -63,12 +64,13 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
this.timestamp = undefined
// VM currently backed up
const tags = (this._tags = vm.tags)
const tags = vm.tags
// VM (snapshot) that is really exported
this._exportedVm = undefined
this._synchronizedSnapshotTimestamp = synchronizedSnapshotTimestamp
this._vm = vm
this._runContext = runContext
this._getSnapshotNameLabel = getSnapshotNameLabel
this._isIncremental = job.mode === 'delta'
@@ -90,6 +92,9 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
settings.offlineSnapshot = true
}
this._settings = settings
this._compileHealthCheckPredicate()
// Create writers
{
const writers = new Set()

View File

@@ -40,6 +40,7 @@
"@xen-orchestra/backup-archive": "^1.2.0",
"app-conf": "^3.0.0",
"compare-versions": "^6.0.0",
"complex-matcher": "^1.1.1",
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",
"golike-defer": "^0.5.1",
@@ -57,6 +58,7 @@
"value-matcher": "^0.2.0",
"vhd-lib": "^4.17.0",
"xen-api": "^5.1.0",
"xo-remote-parser": "^2.0.0",
"yazl": "^2.5.1"
},
"devDependencies": {

View File

@@ -280,7 +280,8 @@ export class CleanupClient {
if (config.deleteBackupFiles) {
try {
const jobDetails = await this.dispatchClient.backup.details(job.id)
const jobRemotes = extractIdsFromSimplePattern(jobDetails.remotes)
const jobRemotes =
typeof jobDetails.remotes === 'string' ? [] : extractIdsFromSimplePattern(jobDetails.remotes)
if (jobRemotes.length > 0) {
const backupsByRemote = await this.dispatchClient.backup.listVmBackups(jobRemotes)

View File

@@ -13,9 +13,9 @@ import { Unbrand } from '../open-api/common/response.common.mjs'
import type * as CMType from '@vates/types/lib/complex-matcher'
type UnbrandedXoVmBackupJob = Omit<XoVmBackupJob, 'settings' | 'remotes' | 'vms' | 'srs'> & {
remotes?: CMType.IdOr<string>
remotes?: CMType.IdOr<string> | string
vms: CMType.IdOr<string> | Record<string, unknown>
srs?: CMType.IdOr<string>
srs?: CMType.IdOr<string> | string
settings: {
'': XoVmBackupJobGeneralSettings
[scheduleId: string]: Unbrand<XoVmBackupJobScheduleSettings> | undefined

View File

@@ -8,18 +8,21 @@ export class BackupRepositoryService {
constructor(@inject(RestApi) restApi: RestApi) {
this.#restApi = restApi
}
isBackupRepositoryReferenced(
idsToCheck: AnyXoBackupJob['remotes'],
repositoryId: XoBackupRepository['id']
): boolean {
isBackupRepositoryReferenced(idsToCheck: AnyXoBackupJob['remotes'], repositoryId: XoBackupRepository['id']): boolean {
if (idsToCheck === undefined) {
return false
}
const { id } = idsToCheck
const ids = typeof id === 'string' ? [id] : id.__or
return ids.includes(repositoryId)
if (typeof idsToCheck === 'string') {
return true
} else {
const { id } = idsToCheck
const ids = typeof id === 'string' ? [id] : id.__or
return ids.includes(repositoryId)
}
}
public async getReferencingJobs(repositoryId: XoBackupRepository['id']): Promise<AnyXoBackupJob['id'][]> {
const allJobs = await this.#restApi.xoApp.getAllJobs()
const referencingJobs: AnyXoBackupJob['id'][] = []

View File

@@ -23,7 +23,7 @@ import BackupJobSchedulesCard from '@/modules/backup-job/components/list/panel/c
import BackupJobSettingsCard from '@/modules/backup-job/components/list/panel/cards/BackupJobSettingsCard.vue'
import BackupJobTargetsCard from '@/modules/backup-job/components/list/panel/cards/BackupJobTargetsCard.vue'
import type { FrontAnyXoBackupJob } from '@/modules/backup-job/remote-resources/use-xo-backup-job-collection.ts'
import { BACKUP_JOB_LAST_RUNS_COUNT } from '@/modules/backup-job/utils/xo-backup-job.util.ts'
import { BACKUP_JOB_LAST_RUNS_COUNT, getTargetIds } from '@/modules/backup-job/utils/xo-backup-job.util.ts'
import { useXoBackupLogCollection } from '@/modules/backup-log/remote-resources/use-xo-backup-log-collection.ts'
import {
useXoBackupRepositoryCollection,
@@ -36,7 +36,6 @@ import {
} from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import VtsSidePanel from '@core/components/panel/VtsSidePanel.vue'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import { extractIdsFromSimplePattern } from '@xen-orchestra/backups/extractIdsFromSimplePattern.mjs'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -66,7 +65,7 @@ const lastBackupLogs = computed(() =>
const backupRepositoryTargets = computed(() =>
backupJob !== undefined
? getBackupRepositoriesByIds(extractIdsFromSimplePattern<FrontXoBackupRepository['id']>(backupJob.remotes))
? getBackupRepositoriesByIds(getTargetIds<FrontXoBackupRepository['id']>(backupJob.remotes))
: []
)
@@ -75,6 +74,6 @@ const storageRepositoryTargets = computed(() => {
return []
}
return getSrsByIds(extractIdsFromSimplePattern<FrontXoSr['id']>(backupJob.srs))
return getSrsByIds(getTargetIds<FrontXoSr['id']>(backupJob.srs))
})
</script>

View File

@@ -11,6 +11,10 @@ export function useXoBackupJobTypeLabels(rawBackupJob: MaybeRefOrGetter<FrontAny
const { t } = useI18n()
function hasIds(pattern: unknown) {
if (typeof pattern === 'string') {
return true
}
return extractIdsFromSimplePattern(pattern).length > 0
}

View File

@@ -4,6 +4,7 @@ import type {
} from '@/modules/backup-job/remote-resources/use-xo-backup-job-collection.ts'
import type { Status } from '@core/components/status/VtsStatus.vue'
import type { VM_POWER_STATE, XoSr } from '@vates/types'
import { extractIdsFromSimplePattern } from '@xen-orchestra/backups/extractIdsFromSimplePattern.mjs'
export const BACKUP_JOB_LAST_RUNS_COUNT = 3
@@ -54,6 +55,14 @@ export function hasBackupJobSnapshotRetention(backupJob: FrontXoVmBackupJob): bo
)
}
export function getTargetIds<TId extends string>(pattern: string | { id: TId | { __or: TId[] } } | undefined): TId[] {
// Expression targets are only resolved when the job runs, so there are no fixed IDs to show
if (typeof pattern === 'string') {
return []
}
return extractIdsFromSimplePattern<TId>(pattern)
}
export function isBackupJobVmsSmartMode(
vmsConfig: FrontXoVmBackupJob['vms'] | undefined
): vmsConfig is VmsSmartModeEnabled {

View File

@@ -6,9 +6,15 @@ export function getBackupJobsUsingBackupRepository(
brId: FrontXoBackupRepository['id'],
backupJobs: FrontAnyXoBackupJob[]
): FrontAnyXoBackupJob[] {
return backupJobs.filter(
backupJob =>
extractIdsFromSimplePattern(backupJob.remotes).includes(brId) ||
('sourceRemote' in backupJob && backupJob.sourceRemote === brId)
)
return backupJobs.filter(backupJob => {
if ('sourceRemote' in backupJob && backupJob.sourceRemote === brId) {
return true
}
if (typeof backupJob.remotes === 'string') {
return true
}
return extractIdsFromSimplePattern(backupJob.remotes).includes(brId)
})
}

View File

@@ -43,4 +43,13 @@ describe('getBackupJobsUsingBackupRepository', () => {
expect(getBackupJobsUsingBackupRepository(br.id, [otherTarget, otherSource])).toEqual([])
})
it('treats a job using an expression as using every repository', () => {
const job = createBackupJob({
id: 'expression',
remotes: 'backupRepository:type:nfs',
})
expect(getBackupJobsUsingBackupRepository(br.id, [job])).toEqual([job])
})
})

View File

@@ -31,6 +31,13 @@
<!--packages-start-->
- @vates/types minor
- @xen-orchestra/backups minor
- @xen-orchestra/qa-test patch
- @xen-orchestra/rest-api patch
- @xen-orchestra/web patch
- @xen-orchestra/web-core minor
- xo-server minor
- xo-web minor
<!--packages-end-->

View File

@@ -789,6 +789,74 @@ To remove one host from a pool, you can go to the "Advanced" tab of the host pag
- The host you want to remove must be a slave, not the master!
:::
### Forget a host that is permanently gone
Detaching only works on a host that is up and reachable. When a host is dead for good (hardware failure, stolen, reinstalled from scratch…), it stays in the pool database as an offline host, and the pool keeps waiting for it. In that case, **forget** the host: this removes its record from the pool database (XAPI `host.destroy`).
:::danger
Only forget a host that will never come back online with its current installation. If the host can be repaired and booted again, fix it and use [Detach](#remove-a-host-from-an-existing-pool) instead. Forgetting a host cannot be undone.
:::
#### Before forgetting the host
1. **Make sure the host is really off.** Xen Orchestra only offers the Forget action when the host is not running. If the machine is still powered on but unreachable, power it off (for example through its IPMI/BMC) before going further.
2. **If the dead host was the pool master**, promote another host first. The pool cannot be managed without a master. On the host that will become the new master, run:
```sh
xe pool-emergency-transition-to-master
xe pool-recover-slaves
```
Then reconnect Xen Orchestra to the new master if needed (Settings > Servers).
3. **Clear the VMs still recorded on the host.** If some VMs were running on the host when it died, the pool still considers them as running there, and forgetting the host fails with this error:
```json
{
"code": "HOST_HAS_RESIDENT_VMS",
"params": ["OpaqueRef:…"]
}
```
List these VMs from a surviving host of the pool:
```sh
xe vm-list resident-on=<dead host UUID> is-control-domain=false params=uuid,name-label,power-state
```
Then mark each of them as halted:
```sh
xe vm-reset-powerstate uuid=<VM UUID> --force
```
:::warning
`vm-reset-powerstate` tells the pool that the VM is stopped, without checking. If the host is in fact still running that VM and you start it on another host, the VM runs twice on the same disks, which corrupts them. Only run this command once you are sure the dead host is powered off.
:::
VMs whose disks are on a shared SR can then be started on another host. VMs whose disks were on the local storage of the dead host cannot be started anymore: restore them from your backups.
#### Forget the host
Once the host is halted and has no resident VMs left, forget it from Xen Orchestra:
- **XO 5**: on the host page, go to the **Advanced** tab and click **Forget**. The button is only shown when the host is not running.
- **XO 6**: on the host page, open the actions menu (**⋮**) and select **Forget**. The action is only enabled when the host is halted.
You can also forget it from the command line, on the pool master:
```sh
xe host-forget uuid=<dead host UUID>
```
For the XCP-ng side of removing a host from a pool (`pool-eject` versus `host-forget`), see [Remove a host from a pool](https://docs.xcp-ng.org/management/hosts-pools/#remove-a-host-from-a-pool) in the XCP-ng documentation.
#### After forgetting the host
- The local SRs of the forgotten host (local storage, removable devices…) can stay behind with no host attached. Once you made sure you don't need them anymore, forget them from the **Storage** view.
- If you want to reuse the same machine, reinstall XCP-ng on it, then add it to the pool like any new host (see [Adding a host to an existing pool](#adding-a-host-to-an-existing-pool)).
### Network bonding
#### Definition

View File

@@ -39,6 +39,8 @@ Two host tabs deserve a special mention:
The remaining tabs (System, Network, Storage, Tasks, VMs) follow the same patterns as pools.
To remove a host that is permanently gone from its pool, use **Forget** in the host action menu. The action is only enabled when the host is halted, and some prerequisites must be handled from the command line: read [Forget a host that is permanently gone](manage_infrastructure.md#forget-a-host-that-is-permanently-gone) first.
## VMs
The VM page is where you will spend most of your time. Its dashboard gathers the quick info (state, IP address, OS, vCPUs, RAM, virtualization mode, guest tools version and tags), the last backup runs and the protection status, the last replication, and the VM alarms.

View File

@@ -0,0 +1,26 @@
import { isExpression, compileExpression } from '@xen-orchestra/backups/_expressionPredicate.mjs'
import { invalidParameters } from 'xo-common/api-errors.js'
export function assertValidSettingsExpression(settings, keys) {
for (const [scheduleId, scheduleSettings] of Object.entries(settings ?? {})) {
for (const key of keys) {
assertValidExpression(`settings/${scheduleId === '' ? 'general' : scheduleId}/${key}`, scheduleSettings?.[key])
}
}
}
export function assertValidExpression(name, value) {
if (isExpression(value)) {
try {
compileExpression(value)
} catch (error) {
throw invalidParameters(`Invalid ${name} expression: ${error.message}`, [
{ instancePath: `/${name}`, message: error.message },
])
}
} else if (typeof value === 'string' && value.trim() === '') {
throw invalidParameters(`Invalid ${name} expression: must be a non-empty expression`, [
{ instancePath: `/${name}`, message: 'must be a non-empty expression' },
])
}
}

View File

@@ -0,0 +1,77 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { invalidParameters } from 'xo-common/api-errors.js'
import { assertValidExpression, assertValidSettingsExpression } from './_backupExpressions.mjs'
const VALID = 'vm:tags:prod'
const INVALID = 'vm:('
// checks that the error is `invalidParameters` and points to the expected field
const isInvalidAt = instancePath => error =>
invalidParameters.is(error) && error.data.errors[0].instancePath === instancePath
describe('assertValidExpression', function () {
it('accepts a valid expression', function () {
assert.doesNotThrow(() => assertValidExpression('srs', VALID))
})
it('rejects an invalid expression with invalidParameters', function () {
assert.throws(() => assertValidExpression('srs', INVALID), isInvalidAt('/srs'))
})
it('ignores values which are not expressions', function () {
for (const value of [undefined, '3', { id: 'sr-1' }, { id: { __or: ['sr-1', 'sr-2'] } }]) {
assert.doesNotThrow(() => assertValidExpression('srs', value))
}
})
it('rejects blank strings', function () {
for (const value of ['', ' ']) {
assert.throws(() => assertValidExpression('srs', value), isInvalidAt('/srs'))
}
})
})
describe('assertValidSettingsExpression', function () {
const KEYS = ['healthCheckVmsWithTags', 'fullInterval']
it('accepts valid expressions in the general and the schedule settings', function () {
assert.doesNotThrow(() =>
assertValidSettingsExpression(
{ '': { fullInterval: VALID }, 'schedule-1': { healthCheckVmsWithTags: VALID } },
KEYS
)
)
})
it('rejects an invalid expression in the schedule settings and points to it', function () {
assert.throws(
() => assertValidSettingsExpression({ '': {}, 'schedule-1': { fullInterval: INVALID } }, KEYS),
isInvalidAt('/settings/schedule-1/fullInterval')
)
})
it('rejects an invalid expression in the general settings', function () {
// the general settings are keyed by '', named "general" in the path.
assert.throws(
() => assertValidSettingsExpression({ '': { healthCheckVmsWithTags: INVALID } }, KEYS),
isInvalidAt('/settings/general/healthCheckVmsWithTags')
)
})
it('ignores the legacy array and number values', function () {
assert.doesNotThrow(() =>
assertValidSettingsExpression({ 'schedule-1': { healthCheckVmsWithTags: ['tag'], fullInterval: 3 } }, KEYS)
)
})
it('ignores the keys it has not been asked to check', function () {
assert.doesNotThrow(() => assertValidSettingsExpression({ 'schedule-1': { vmFilter: INVALID } }, KEYS))
})
it('handles missing settings and undefined entries', function () {
assert.doesNotThrow(() => assertValidSettingsExpression(undefined, KEYS))
assert.doesNotThrow(() => assertValidSettingsExpression({ 'schedule-1': undefined }, KEYS))
})
})

View File

@@ -5,6 +5,7 @@ import { pipeline } from 'readable-stream'
import createNdJsonStream from '../_createNdJsonStream.mjs'
import { REMOVE_CACHE_ENTRY } from '../_pDebounceWithKey.mjs'
import { safeDateFormat } from '../utils.mjs'
import { assertValidSettingsExpression, assertValidExpression } from './_backupExpressions.mjs'
const SCHEMA_SETTINGS = {
type: 'object',
@@ -64,6 +65,10 @@ const SCHEMA_SETTINGS = {
}
export function createJob({ schedules, ...job }) {
assertValidExpression('srs', job.srs)
assertValidExpression('remotes', job.remotes)
assertValidSettingsExpression(job.settings, ['healthCheckVmsWithTags', 'fullInterval'])
return this.createBackupNgJob('backup', job, schedules).then(({ id }) => id)
}
@@ -85,7 +90,7 @@ createJob.params = {
optional: true,
},
remotes: {
type: 'object',
type: ['object', 'string'],
optional: true,
},
schedules: {
@@ -94,7 +99,7 @@ createJob.params = {
},
settings: SCHEMA_SETTINGS,
srs: {
type: 'object',
type: ['object', 'string'],
optional: true,
},
vms: {
@@ -118,6 +123,10 @@ deleteJob.params = {
}
export function editJob(props) {
assertValidExpression('srs', props.srs)
assertValidExpression('remotes', props.remotes)
assertValidSettingsExpression(props.settings, ['healthCheckVmsWithTags', 'fullInterval'])
return this.updateJob(props)
}
@@ -143,12 +152,12 @@ editJob.params = {
optional: true,
},
remotes: {
type: 'object',
type: ['object', 'string'],
optional: true,
},
settings: SCHEMA_SETTINGS,
srs: {
type: 'object',
type: ['object', 'string'],
optional: true,
},
vms: {
@@ -176,6 +185,8 @@ getJob.params = {
}
export async function runJob({ id, schedule, settings, vm, vms = vm !== undefined ? [vm] : undefined }) {
assertValidSettingsExpression(settings, ['healthCheckVmsWithTags', 'fullInterval'])
return this.runJobSequence([id], await this.getSchedule(schedule), {
settings,
vms,

View File

@@ -1,3 +1,5 @@
import { assertValidSettingsExpression } from './_backupExpressions.mjs'
const SCHEMA_SETTINGS = {
type: 'object',
properties: {
@@ -47,6 +49,8 @@ const MIRROR_BACKUP_FILTER = {
}
export function createJob({ schedules, ...job }) {
assertValidSettingsExpression(job.settings, ['healthCheckVmsWithTags', 'vmFilter'])
return this.createBackupNgJob('mirrorBackup', job, schedules).then(({ id }) => id)
}
@@ -88,6 +92,8 @@ deleteJob.params = {
}
export function editJob(props) {
assertValidSettingsExpression(props.settings, ['healthCheckVmsWithTags', 'vmFilter'])
return this.updateJob(props)
}

View File

@@ -0,0 +1,42 @@
import { isSrWritable } from '../../utils.mjs'
import { buildSrContext, buildBackupRepositoryContext } from '@xen-orchestra/backups/_buildContext.mjs'
import { compileExpression } from '@xen-orchestra/backups/_expressionPredicate.mjs'
export function resolveBackupSrs(job, srs, runContext) {
const predicate = compileExpression(job.srs)
const srUuids = []
srs.forEach(sr => {
if (isSrWritable(sr) && predicate(buildSrContext(sr, runContext))) {
srUuids.push(sr.uuid)
}
})
return srUuids
}
export function resolveBackupRepositories(job, backupRepositories, runContext) {
const predicate = compileExpression(job.remotes)
const backupRepositoryIds = []
const proxySkippedIds = []
const invalidUrlIds = []
backupRepositories.forEach(backupRepository => {
if (!backupRepository.enabled) return
const context = buildBackupRepositoryContext(backupRepository, runContext)
if (predicate(context)) {
if (context.backupRepository.type !== undefined) {
if (backupRepository.proxy === job.proxy) {
backupRepositoryIds.push(backupRepository.id)
} else {
proxySkippedIds.push(backupRepository.id)
}
} else {
invalidUrlIds.push(backupRepository.id)
}
}
})
return { backupRepositoryIds, proxySkippedIds, invalidUrlIds }
}

View File

@@ -0,0 +1,150 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { buildRunContext } from '@xen-orchestra/backups/_buildContext.mjs'
import { resolveBackupRepositories, resolveBackupSrs } from './_resolveBackupTargets.mjs'
// a Monday, at 22:00 UTC
const RUN_CONTEXT = buildRunContext(new Date('2026-11-02T22:00:00Z'), 'UTC')
const sr = ({ uuid, name_label = uuid, SR_type = 'nfs', content_type = 'user', size = 100, tags = [] }) => ({
uuid,
name_label,
name_description: '',
SR_type,
content_type,
size,
tags,
})
const backupRepository = ({ id, name = id, url = 'nfs://nas:/backups', enabled = true, proxy }) => ({
id,
name,
url,
enabled,
proxy,
})
describe('resolveBackupSrs()', () => {
it('returns the SRs matching the expression', () => {
const srs = [sr({ uuid: 'nfs-sr' }), sr({ uuid: 'local-sr', SR_type: 'ext' })]
assert.deepEqual(resolveBackupSrs({ srs: 'sr:type:nfs' }, srs, RUN_CONTEXT), ['nfs-sr'])
})
it('never returns an ISO or empty SR', () => {
const srs = [sr({ uuid: 'writable' }), sr({ uuid: 'iso', content_type: 'iso' }), sr({ uuid: 'empty', size: 0 })]
assert.deepEqual(resolveBackupSrs({ srs: 'sr:type:nfs' }, srs, RUN_CONTEXT), ['writable'])
})
it('evaluates the run context', () => {
const srs = [sr({ uuid: 'nfs-sr' })]
assert.deepEqual(resolveBackupSrs({ srs: 'run:hour:22' }, srs, RUN_CONTEXT), ['nfs-sr'])
assert.deepEqual(resolveBackupSrs({ srs: 'run:hour:23' }, srs, RUN_CONTEXT), [])
})
it('returns an empty array when nothing matches', () => {
const srs = [sr({ uuid: 'nfs-sr' })]
assert.deepEqual(resolveBackupSrs({ srs: 'sr:name_label:missing' }, srs, RUN_CONTEXT), [])
})
})
describe('resolveBackupRepositories()', () => {
it('returns the backup repositories matching the expression', () => {
const backupRepositories = [
backupRepository({ id: 'nfs-br' }),
backupRepository({ id: 'local-br', url: 'file:///media/backups' }),
]
assert.deepEqual(
resolveBackupRepositories({ remotes: 'backupRepository:type:nfs' }, backupRepositories, RUN_CONTEXT),
{
backupRepositoryIds: ['nfs-br'],
proxySkippedIds: [],
invalidUrlIds: [],
}
)
})
it('skips a disabled backup repository silently', () => {
const backupRepositories = [
backupRepository({ id: 'enabled' }),
backupRepository({ id: 'disabled', enabled: false }),
]
assert.deepEqual(
resolveBackupRepositories({ remotes: 'backupRepository:type:nfs' }, backupRepositories, RUN_CONTEXT),
{
backupRepositoryIds: ['enabled'],
proxySkippedIds: [],
invalidUrlIds: [],
}
)
})
it('skips a matching backup repository linked to another proxy', () => {
const backupRepositories = [
backupRepository({ id: 'same-proxy', proxy: 'proxy-1' }),
backupRepository({ id: 'other-proxy', proxy: 'proxy-2' }),
backupRepository({ id: 'no-proxy' }),
]
assert.deepEqual(
resolveBackupRepositories(
{ remotes: 'backupRepository:type:nfs', proxy: 'proxy-1' },
backupRepositories,
RUN_CONTEXT
),
{ backupRepositoryIds: ['same-proxy'], proxySkippedIds: ['other-proxy', 'no-proxy'], invalidUrlIds: [] }
)
})
it('skips a matching backup repository whose URL cannot be parsed', () => {
const backupRepositories = [
backupRepository({ id: 'nfs-br' }),
backupRepository({ id: 'invalid', url: 'foo://bar' }),
]
// without the URL check, the negation would pick the invalid repository, its type being undefined
assert.deepEqual(
resolveBackupRepositories({ remotes: '!backupRepository:type:nfs' }, backupRepositories, RUN_CONTEXT),
{
backupRepositoryIds: [],
proxySkippedIds: [],
invalidUrlIds: ['invalid'],
}
)
})
it('does not report an invalid backup repository the expression does not match', () => {
const backupRepositories = [
backupRepository({ id: 'nfs-br' }),
backupRepository({ id: 'invalid', url: 'foo://bar' }),
]
assert.deepEqual(
resolveBackupRepositories({ remotes: 'backupRepository:name:nfs-br' }, backupRepositories, RUN_CONTEXT),
{
backupRepositoryIds: ['nfs-br'],
proxySkippedIds: [],
invalidUrlIds: [],
}
)
})
it('returns empty arrays when nothing matches', () => {
const backupRepositories = [backupRepository({ id: 'nfs-br' })]
assert.deepEqual(
resolveBackupRepositories({ remotes: 'backupRepository:type:s3' }, backupRepositories, RUN_CONTEXT),
{
backupRepositoryIds: [],
proxySkippedIds: [],
invalidUrlIds: [],
}
)
})
})

View File

@@ -22,6 +22,9 @@ import { serializeError, unboxIdsFromPattern } from '../../utils.mjs'
import { serveVmBackups, VmBackupsCache } from './_vmBackupsCache.mjs'
import { VmBackupsSource } from './_vmBackupsSource.mjs'
import { waitAll } from '../../_waitAll.mjs'
import { isExpression } from '@xen-orchestra/backups/_expressionPredicate.mjs'
import { buildRunContext } from '@xen-orchestra/backups/_buildContext.mjs'
import { resolveBackupSrs, resolveBackupRepositories } from './_resolveBackupTargets.mjs'
const logger = createLogger('xo:xo-mixins:backups-ng')
@@ -190,7 +193,68 @@ export default class BackupNg {
let vmIds
const timezone = job.settings[schedule.id]?.timezone ?? job.settings['']?.timezone ?? schedule.timezone
const runContext = buildRunContext(new Date(), timezone)
job = { ...job, runContext }
if (job.type === 'backup') {
const srsExpression = isExpression(job.srs) ? job.srs : undefined
const remotesExpression = isExpression(job.remotes) ? job.remotes : undefined
if (isExpression(job.srs)) {
const srUuids = resolveBackupSrs(job, Object.values(app.getObjectsByType('SR')), runContext)
job.srs = { id: { __or: srUuids } }
}
if (isExpression(job.remotes)) {
const {
backupRepositoryIds: remoteIds,
proxySkippedIds,
invalidUrlIds,
} = resolveBackupRepositories(job, await app.getAllRemotes(), runContext)
if (proxySkippedIds.length > 0) {
jobsLogger.warning(
'some backup repositories matched the expression but are not linked to the proxy of this job, they are skipped',
{
event: 'task.warning',
taskId: runJobId,
data: { backupRepositories: proxySkippedIds, proxy: job.proxy },
}
)
}
if (invalidUrlIds.length > 0) {
jobsLogger.warning(
'some backup repositories matched the expression but have an invalid URL, they are skipped',
{
event: 'task.warning',
taskId: runJobId,
data: { backupRepositories: invalidUrlIds },
}
)
}
job.remotes = { id: { __or: remoteIds } }
}
if (
(srsExpression !== undefined || remotesExpression !== undefined) &&
unboxIdsFromPattern(job.srs).length === 0 &&
unboxIdsFromPattern(job.remotes).length === 0
) {
jobsLogger.warning(
'no SR or backup repository matched the expressions, nothing will be backed up or replicated',
{
event: 'task.warning',
taskId: runJobId,
data: { srs: srsExpression, remotes: remotesExpression },
}
)
}
const vmsPattern = job.vms
// Make sure we are passing only the VM to run which can be
@@ -255,7 +319,6 @@ export default class BackupNg {
job = {
...job,
vms: { id: { __or: vmIds } },
settings: merge(job.settings, data?.settings),
}

View File

@@ -241,9 +241,13 @@ export default class {
if (idsToCheck === undefined) {
return false
}
const { id } = idsToCheck
const ids = typeof id === 'string' ? [id] : id.__or
return ids.includes(remoteId)
if (typeof idsToCheck === 'string') {
return true
} else {
const { id } = idsToCheck
const ids = typeof id === 'string' ? [id] : id.__or
return ids.includes(remoteId)
}
}
// The caller of this function have to create a parent Task

View File

@@ -637,6 +637,7 @@ const messages = {
'Delete old backups before backing up the VMs. If the new backup fails, you will lose your old backups.',
customTag: 'Custom tag',
editJobNotFound: "The job you're trying to edit wasn't found",
editJobUsesExpressions: 'This job uses expressions which are not supported in XO 5, edit it in XO 6',
preferNbd: 'Use NBD to transfer disk if available',
preferNbdInformation: 'A network accessible by XO or the proxy must have NBD enabled.',
distributeBackups: 'Distribute backups across backup repositories',

View File

@@ -18,6 +18,7 @@ import Metadata from './new/metadata'
import New from './new'
import NewMirrorBackup from './new/mirror'
import NewSequence from './new/sequence'
import { usesExpressions } from './utils'
export default decorate([
addSubscriptions({
@@ -56,6 +57,10 @@ export default decorate([
<span className='text-danger'>
<Icon icon='error' /> {_('editJobNotFound')}
</span>
) : usesExpressions(job) ? (
<span className='text-danger'>
<Icon icon='error' /> {_('editJobUsesExpressions')}
</span>
) : job.type === 'backup' ? (
<New job={job} schedules={schedules} />
) : job.type === 'mirrorBackup' ? (

View File

@@ -21,6 +21,13 @@ export const constructPattern = values =>
},
}
export const usesExpressions = job =>
typeof job.srs === 'string' ||
typeof job.remotes === 'string' ||
Object.values(job.settings ?? {}).some(settings =>
['healthCheckVmsWithTags', 'fullInterval', 'vmFilter'].some(key => typeof settings?.[key] === 'string')
)
export const FormFeedback = ({ component: Component, error, message, ...props }) => (
<div>
<Component