Compare commits

...

7 Commits

Author SHA1 Message Date
Olivier Lambert
e634802822 chore: changelog 2026-11-05 08:04:02 +01:00
Olivier Lambert
bec7d28403 fix(xo-server/backups): never replicate to an SR of a disconnected pool
The SRs matched by the expression of a backup job now include the stale
SRs of a disconnected pool, which cannot receive anything: they are
skipped, as when they were not kept.
2026-11-04 22:33:30 +01:00
Olivier Lambert
6885cb870d fix(xo-server,rest-api): keep the objects of a failed reconnection, skip stale hosts
- a failed attempt to reconnect a lost server, e.g. which times out while
  fetching its objects, removed the objects it had already replaced: they
  are now kept, flagged, like the others, until the next connection
- the missing patches of every pool (XOA and pool dashboards) failed as
  soon as a pool was stale: its hosts are now counted as failed, and a
  host whose XAPI cannot be found no longer fails the others
2026-11-04 22:32:22 +01:00
Olivier Lambert
4155fcbe17 chore: changelog 2026-11-04 22:32:22 +01:00
Olivier Lambert
f470b1f5c7 feat(xo-server): keep the objects of a lost pool, flagged as stale
When XO loses a pool, it removed all its objects, which made the pool vanish
from the UI even during a short outage. They are now kept with a `$lastSeen`
property (timestamp of the last successful event fetch) until the pool is
reconnected, and replaced by the fresh objects on reconnection.

- the objects are flagged after `xapiMarkDisconnectedDelay`, lowered from 5
  minutes to 30 seconds since flagging is not destructive, or on an
  unexpected disconnection
- they are removed on a deliberate disconnection, and anyway after the new
  `xapiStaleObjectsMaxAge` setting (1 hour)
- actions on a stale object fail with `the pool of object X is disconnected,
  last seen at …`
- the timeout to fetch the objects of a pool on connection, which reused
  `xapiMarkDisconnectedDelay`, is the new `xapiObjectsFetchTimeout` setting
  (5 minutes)
- the load balancer, perf alert, usage report and Netbox plugins skip stale
  objects
2026-11-04 22:31:45 +01:00
Olivier Lambert
7f6ea616ca feat(docs): add optional email to page feedback (#10697) 2026-11-04 16:58:50 +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
49 changed files with 1796 additions and 254 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

@@ -52,6 +52,12 @@ type BaseXapiXo = {
*/
$poolId: XoPool['id']
/**
* Only present while the pool is disconnected: the object is kept but may be
* outdated, this is the last time (in milliseconds) the pool was reached
*/
$lastSeen?: number
_xapiRef: string
uuid: string
@@ -552,8 +558,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 +570,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 +604,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

@@ -75,9 +75,14 @@ export class HostService {
let nHostsFailed = 0
await asyncEach(hosts, async (host: XoHost) => {
const xapi = this.#restApi.xoApp.getXapi(host)
// its pool is disconnected: what it misses is unknown until it is back
if (host.$lastSeen !== undefined) {
nHostsFailed++
return
}
try {
const xapi = this.#restApi.xoApp.getXapi(host)
const patches = await xapi.listMissingPatches(host.id)
if (patches.length > 0) {

View File

@@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { HostService } from './host.service.mjs'
import type { RestApi } from '../rest-api/rest-api.mjs'
describe('HostService.getMissingPatchesInfo', () => {
it('counts the hosts of a disconnected pool as failed, and still reports the others', async () => {
const hosts = {
live: { id: 'live', $pool: 'pool-live' },
stale: { id: 'stale', $pool: 'pool-stale', $lastSeen: Date.now() },
}
const queried: string[] = []
const xapi = {
listMissingPatches: async (hostId: string) => {
queried.push(hostId)
return [{ name: 'patch-1' }]
},
}
const restApi = {
getObjectsByType: () => hosts,
xoApp: {
checkFeatureAuthorization: async () => {},
getXapi: (host: { $lastSeen?: number }) => {
if (host.$lastSeen !== undefined) {
throw new Error('the pool of this object is disconnected')
}
return xapi
},
},
} as unknown as RestApi
const info = await new HostService(restApi).getMissingPatchesInfo()
assert.deepEqual(queried, ['live'])
assert.equal(info.nHostsFailed, 1)
assert.equal(info.nHostsWithMissingPatches, 1)
assert.equal(info.nPoolsWithMissingPatches, 1)
assert.deepEqual(info.missingPatches, [{ name: 'patch-1' }])
})
it('counts a host whose XAPI cannot be found as failed, instead of failing every pool', async () => {
const restApi = {
getObjectsByType: () => ({ gone: { id: 'gone', $pool: 'pool-gone' } }),
xoApp: {
checkFeatureAuthorization: async () => {},
getXapi: () => {
throw new Error('no connection found for this object')
},
},
} as unknown as RestApi
const info = await new HostService(restApi).getMissingPatchesInfo()
assert.equal(info.nHostsFailed, 1)
assert.deepEqual(info.missingPatches, [])
})
})

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

@@ -11,6 +11,9 @@
> Users must be able to say: "Nice enhancement, I'm eager to test it"
- [Servers] The objects of a pool which can no longer be reached are kept and flagged as disconnected, with the last time the pool was reached, instead of disappearing. They are flagged after 30 seconds instead of 5 minutes (`xapiMarkDisconnectedDelay`), and removed after 1 hour (`xapiStaleObjectsMaxAge`) (PR [#10611](https://github.com/vatesfr/xen-orchestra/pull/10611))
- [Servers] The time allowed to fetch the objects of a pool on connection is now set by `xapiObjectsFetchTimeout` (5 minutes), no longer by `xapiMarkDisconnectedDelay` (PR [#10611](https://github.com/vatesfr/xen-orchestra/pull/10611))
### Bug fixes
> Users must be able to say: "I had this issue, happy to know it's fixed"
@@ -31,6 +34,17 @@
<!--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-server-load-balancer patch
- xo-server-netbox patch
- xo-server-perf-alert patch
- xo-server-usage-report patch
- xo-web minor
<!--packages-end-->

View File

@@ -39,3 +39,28 @@ $ GIT_USER=<Your GitHub username> yarn deploy
```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
### Feedback follow-up
The page feedback widget accepts an optional email address after a negative vote.
It sends the address as `data.email` in the same Formbricks response as the comment,
only when the visitor selects **Send**. An empty address is omitted; **Skip** saves
only the vote. Email addresses are not included in Matomo events.
Before deploying this widget, add an optional **Open Text** question to the
Formbricks survey configured in `customFields.formbricks`:
- Question ID: `email` (the API key, not just the displayed label).
- Input type: `email`.
- Required: off.
- Label: `Want a follow-up? (optional)`.
- Description: `We'll only use this to ask about your feedback or let you know when it's addressed.`
Keep the existing question IDs and reason choices unchanged. Apply the same field
to the XCP-ng, Xen Orchestra, and Product docs surveys before deploying their
respective widgets. Verify that a response with an email is visible in Formbricks
and that anonymous feedback still works.
Follow-ups are manual. Use the address only to clarify the report or reply once
the correction is live, and record the reply in a private triage note. Do not copy
the address into public issues or pull requests.

View File

@@ -1,202 +1,247 @@
import React, {useEffect, useRef, useState} from 'react';
import {useLocation} from '@docusaurus/router';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import styles from './styles.module.css';
import React, { useEffect, useId, useRef, useState } from 'react'
import { useLocation } from '@docusaurus/router'
import useDocusaurusContext from '@docusaurus/useDocusaurusContext'
import styles from './styles.module.css'
type FormbricksConfig = {
apiHost: string;
environmentId: string;
surveyId: string;
};
apiHost: string
environmentId: string
surveyId: string
}
type Vote = 'Yes' | 'No'
type Step = 'vote' | 'why' | 'done'
// Labels must match the choice labels of the "XO Docs page feedback" survey in Formbricks.
const REASONS = [
'Outdated',
'Unclear or confusing',
'Missing information',
'Inaccurate',
];
type Step = 'vote' | 'why' | 'done';
const REASONS = ['Outdated', 'Unclear or confusing', 'Missing information', 'Inaccurate']
// Mirror feedback into Matomo (when tracking is enabled) so quality
// signals can be crossed with traffic data, e.g. thumbs-down per view.
const trackFeedbackEvent = (action: string, page: string) => {
window._paq?.push(['trackEvent', 'doc_feedback', action, page]);
};
window._paq?.push(['trackEvent', 'doc_feedback', action, page])
}
export default function PageFeedback(): React.ReactElement | null {
const {siteConfig} = useDocusaurusContext();
const formbricks = siteConfig.customFields?.formbricks as FormbricksConfig;
const {pathname} = useLocation();
const [step, setStep] = useState<Step>('vote');
const [reasons, setReasons] = useState<string[]>([]);
const [details, setDetails] = useState('');
const [sending, setSending] = useState(false);
const responseId = useRef<string | null>(null);
const votePending = useRef<Promise<void> | null>(null);
// A client-side navigation lands on a new page: reset the widget.
useEffect(() => {
setStep('vote');
setReasons([]);
setDetails('');
responseId.current = null;
votePending.current = null;
}, [pathname]);
const responseData = (vote: 'Yes' | 'No') => ({
vote,
page: pathname,
...(reasons.length > 0 ? {reason: reasons} : {}),
...(details.trim() ? {details: details.trim()} : {}),
});
const send = async (
method: 'POST' | 'PUT',
vote: 'Yes' | 'No',
finished: boolean,
) => {
const base = `${formbricks.apiHost}/api/v1/client/${formbricks.environmentId}/responses`;
const url = method === 'PUT' ? `${base}/${responseId.current}` : base;
try {
const res = await fetch(url, {
method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
...(method === 'POST'
? {surveyId: formbricks.surveyId, meta: {url: window.location.href}}
: {}),
finished,
data: responseData(vote),
}),
});
if (method === 'POST' && res.ok) {
const json = await res.json();
responseId.current = json?.data?.id ?? null;
}
} catch {
// Feedback must never break the docs. Votes lost to network
// errors or blockers are acceptable.
}
};
const vote = (value: 'Yes' | 'No') => {
setStep(value === 'Yes' ? 'done' : 'why');
trackFeedbackEvent(value === 'Yes' ? 'up' : 'down', pathname);
// Record the vote right away so it is kept even if the visitor
// leaves without answering the follow-up.
votePending.current = send('POST', value, value === 'Yes');
};
const submitWhy = async () => {
setSending(true);
reasons.forEach((reason) => trackFeedbackEvent(`reason:${reason}`, pathname));
// Wait for the initial vote POST so we update its response instead
// of creating a duplicate when the visitor answers quickly.
await votePending.current;
await send(responseId.current ? 'PUT' : 'POST', 'No', true);
setSending(false);
setStep('done');
};
const toggleReason = (reason: string) =>
setReasons((prev) =>
prev.includes(reason)
? prev.filter((r) => r !== reason)
: [...prev, reason],
);
const { siteConfig } = useDocusaurusContext()
const formbricks = siteConfig.customFields?.formbricks as FormbricksConfig
const { pathname } = useLocation()
if (!formbricks?.environmentId) {
return null;
return null
}
return <FeedbackForm key={pathname} formbricks={formbricks} pathname={pathname} />
}
function FeedbackForm({ formbricks, pathname }: { formbricks: FormbricksConfig; pathname: string }) {
const [step, setStep] = useState<Step>('vote')
const [reasons, setReasons] = useState<string[]>([])
const [details, setDetails] = useState('')
const [email, setEmail] = useState('')
const [sending, setSending] = useState(false)
const [error, setError] = useState(false)
const emailId = useId()
const active = useRef(true)
const responseId = useRef<string | null>(null)
const votePending = useRef<Promise<boolean> | null>(null)
const request = useRef<AbortController | null>(null)
const trackedVote = useRef<Vote | null>(null)
// Each page gets its own form and response. Abort any request when leaving it.
useEffect(() => {
active.current = true
return () => {
active.current = false
request.current?.abort()
}
}, [])
const send = async (value: Vote, finished: boolean, includeDetails = false) => {
const base = `${formbricks.apiHost}/api/v1/client/${formbricks.environmentId}/responses`
const id = responseId.current
const controller = new AbortController()
request.current = controller
const timeout = setTimeout(() => controller.abort(), 15000)
try {
const res = await fetch(id ? `${base}/${id}` : base, {
method: id ? 'PUT' : 'POST',
signal: controller.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...(!id ? { surveyId: formbricks.surveyId, meta: { url: window.location.href } } : {}),
finished,
data: {
vote: value,
page: pathname,
...(includeDetails
? {
...(reasons.length > 0 ? { reason: reasons } : {}),
...(details.trim() ? { details: details.trim() } : {}),
...(email.trim() ? { email: email.trim() } : {}),
}
: {}),
},
}),
})
if (!res.ok) return false
if (!id) {
const json = await res.json()
if (!json?.data?.id) return false
responseId.current = json.data.id
}
return true
} catch {
return false
} finally {
clearTimeout(timeout)
}
}
const vote = async (value: Vote) => {
setError(false)
if (trackedVote.current !== value) {
trackFeedbackEvent(value === 'Yes' ? 'up' : 'down', pathname)
trackedVote.current = value
}
// Save the vote even if the visitor leaves without completing the follow-up.
votePending.current = send(value, value === 'Yes')
if (value === 'No') {
setStep('why')
return
}
setSending(true)
const saved = await votePending.current
if (!active.current) return
setSending(false)
if (saved) setStep('done')
else setError(true)
}
const finish = async (includeDetails: boolean) => {
setSending(true)
setError(false)
// Wait for the vote so that follow-up details update the same response.
await votePending.current
if (!active.current) return
const saved = await send('No', true, includeDetails)
if (!active.current) return
setSending(false)
if (saved) {
if (includeDetails) {
reasons.forEach(reason => trackFeedbackEvent(`reason:${reason}`, pathname))
}
setEmail('')
setStep('done')
} else {
setError(true)
}
}
const submitWhy = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
void finish(true)
}
const toggleReason = (reason: string) =>
setReasons(prev => (prev.includes(reason) ? prev.filter(r => r !== reason) : [...prev, reason]))
return (
<div className={styles.feedback}>
{step === 'vote' && (
<div className={styles.voteRow}>
<span className={styles.question}>Was this page helpful?</span>
<button
type="button"
className={styles.voteButton}
onClick={() => vote('Yes')}>
<button type='button' className={styles.voteButton} disabled={sending} onClick={() => vote('Yes')}>
<ThumbIcon /> Yes
</button>
<button
type="button"
className={styles.voteButton}
onClick={() => vote('No')}>
<button type='button' className={styles.voteButton} disabled={sending} onClick={() => vote('No')}>
<ThumbIcon down /> No
</button>
</div>
)}
{step === 'why' && (
<div className={styles.why}>
<span className={styles.question}>
Sorry about that. What was the problem?
</span>
<form className={styles.why} onSubmit={submitWhy}>
<span className={styles.question}>Sorry about that. What was the problem?</span>
<div className={styles.reasons}>
{REASONS.map((reason) => (
{REASONS.map(reason => (
<button
key={reason}
type="button"
type='button'
className={styles.reason}
aria-pressed={reasons.includes(reason)}
onClick={() => toggleReason(reason)}>
disabled={sending}
onClick={() => toggleReason(reason)}
>
{reason}
</button>
))}
</div>
<textarea
className={styles.details}
aria-label="Additional details"
disabled={sending}
aria-label='Additional details'
rows={3}
placeholder="Tell us what's wrong or missing... (optional)"
value={details}
onChange={(e) => setDetails(e.target.value)}
onChange={e => setDetails(e.target.value)}
/>
<div className={styles.actions}>
<button
type="button"
className={styles.submit}
<div className={styles.followUp}>
<label htmlFor={emailId}>Want a follow-up? (optional)</label>
<input
id={emailId}
className={styles.email}
type='email'
name='email'
autoComplete='email'
aria-describedby={`${emailId}-help`}
placeholder='Email address'
maxLength={254}
value={email}
disabled={sending}
onClick={submitWhy}>
Send
onChange={e => setEmail(e.target.value)}
/>
<small id={`${emailId}-help`}>
We&apos;ll only use this to ask about your feedback or let you know when it&apos;s addressed.
</small>
</div>
<div className={styles.actions}>
<button type='submit' className={styles.submit} disabled={sending}>
{sending ? 'Sending...' : 'Send'}
</button>
<button
type="button"
className={styles.skip}
onClick={() => setStep('done')}>
<button type='button' className={styles.skip} disabled={sending} onClick={() => void finish(false)}>
Skip
</button>
</div>
</div>
</form>
)}
{step === 'done' && (
<span className={styles.thanks}>Thanks for your feedback!</span>
{error && (
<p className={styles.error} role='alert'>
We couldn&apos;t send your feedback. Please try again.
</p>
)}
{step === 'done' && <span className={styles.thanks}>Thanks for your feedback!</span>}
</div>
);
)
}
function ThumbIcon({down = false}: {down?: boolean}) {
function ThumbIcon({ down = false }: { down?: boolean }) {
return (
<svg
className={down ? styles.thumbDown : undefined}
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M7 10v12" />
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
width='14'
height='14'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
>
<path d='M7 10v12' />
<path d='M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z' />
</svg>
);
)
}

View File

@@ -72,7 +72,8 @@
color: var(--ifm-color-primary-contrast-foreground, #fff);
}
.details {
.details,
.email {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--ifm-color-emphasis-300);
@@ -80,7 +81,6 @@
background: var(--ifm-background-color);
color: var(--ifm-font-color-base);
font: inherit;
resize: vertical;
}
.actions {
@@ -116,3 +116,33 @@
.thanks {
font-weight: 600;
}
.details {
resize: vertical;
}
.followUp {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.email {
max-width: 24rem;
}
.followUp small {
color: var(--ifm-font-color-secondary);
}
.voteButton:disabled,
.reason:disabled,
.skip:disabled {
opacity: 0.6;
cursor: default;
}
.error {
margin: 0.6rem 0 0;
color: var(--ifm-color-danger);
}

View File

@@ -258,6 +258,8 @@ export default class Plan {
this.xo.getObjects(),
object =>
object.type === 'host' &&
// stale objects of a disconnected pool cannot be acted upon
object.$lastSeen === undefined &&
includes(this._poolIds, object.$poolId) &&
object.power_state === powerState &&
!includes(this._excludedHosts, object.id) &&
@@ -270,6 +272,7 @@ export default class Plan {
this.xo.getObjects(),
object =>
object.type === 'VM' &&
object.$lastSeen === undefined &&
object.power_state === 'Running' &&
!object.tags.some(tag => this._globalOptions.ignoredVmTags.has(tag))
)

View File

@@ -437,7 +437,9 @@ class Netbox {
const clustersToUpdate = []
for (const xoPoolId of xoPools) {
const xoPool = this.getObject(xoPoolId)
if (xoPool === undefined) {
// the objects of a disconnected pool are stale: synchronizing them would
// spread outdated data, skip the pool as if it was not found
if (xoPool === undefined || xoPool.$lastSeen !== undefined) {
// If we can't find the pool, don't synchronize anything within that pool
log.warn('Synchronizing pools: cannot find pool', { pool: xoPoolId })
delete allNbClusters[xoPoolId]

View File

@@ -303,7 +303,8 @@ export class RrdHostVm extends MonitorStrategy {
* @type {Array<XoHost>}
*/
const hosts = Object.values(this.#xo.objects.indexes.type.host ?? {})
// the RRDs of the stale hosts of a disconnected pool are not reachable
const hosts = Object.values(this.#xo.objects.indexes.type.host ?? {}).filter(host => host.$lastSeen === undefined)
/**
* @type {Map<XoHost['id'], Set<XoVm['id']>}
*/

View File

@@ -197,7 +197,8 @@ export class XapiPerfmon extends MonitorStrategy {
*/
async updateObjectsPerfmon(xoObjects) {
return asyncEach(Object.values(xoObjects), xoObject => {
if (!['host', 'VM'].includes(xoObject.type)) {
// stale objects of a disconnected pool cannot be configured
if (!['host', 'VM'].includes(xoObject.type) || xoObject.$lastSeen !== undefined) {
return
}
process.stdout.write('.')

View File

@@ -659,7 +659,8 @@ async function computeEvolution({ storedStatsPath, ...newStats }) {
}
async function dataBuilder({ currDate, periodicity, xo, storedStatsPath, all }) {
const xoObjects = values(xo.getObjects())
// objects of disconnected pools are kept by XO but their stats are not reachable
const xoObjects = values(xo.getObjects()).filter(object => object.$lastSeen === undefined)
const runningVms = filter(xoObjects, { type: 'VM', power_state: 'Running' })
const haltedVms = filter(xoObjects, { type: 'VM', power_state: 'Halted' })
const runningHosts = filter(xoObjects, {

View File

@@ -34,8 +34,17 @@ guessVhdSizeOnImport = true
# be turned for investigation by the administrator.
verboseApiLogsOnErrors = false
# if no events could be fetched during this delay, the server will be marked as disconnected
xapiMarkDisconnectedDelay = '5 minutes'
# if no events could be fetched during this delay, the objects of the pool are
# flagged as stale and the server is shown as connecting until events are back
xapiMarkDisconnectedDelay = '30 seconds'
# if the objects of a pool could not be fetched during this delay, the
# connection to it fails
xapiObjectsFetchTimeout = '5 minutes'
# once a pool is marked as disconnected, its objects are kept, flagged with
# `$lastSeen`, until it is reconnected or at most during this delay
xapiStaleObjectsMaxAge = '1 hour'
# Delay before re-enabling the load balancer after a rolling pool update
loadBalancerReEnableDelay = '30 minutes'

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,298 @@
import assert from 'node:assert/strict'
import EventEmitter from 'node:events'
import { afterEach, describe, it, mock } from 'node:test'
import { setTimeout as sleep } from 'node:timers/promises'
import { configure } from '@xen-orchestra/log/configure'
configure({ level: 'FATAL', transport: () => {} })
// default value of the duration settings (`xapiMarkDisconnectedDelay`,
// `xapiStaleObjectsMaxAge` and `xapiObjectsFetchTimeout`)
const DELAY = 100
const LAST_SEEN = 1e12
const xapis = []
// XAPI objects provided by the next connection
let nextXapiObjects
// time taken by the next connection to fetch its objects
let nextObjectsFetchDuration
const xapiObject = ($type, $id) => ({ $id, $ref: `OpaqueRef:${$id}`, $type })
class FakeXapi extends EventEmitter {
lastEventFetchedTimestamp = LAST_SEEN
objectsFetched = sleep(nextObjectsFetchDuration)
pool = { $id: 'pool-1', $ref: 'OpaqueRef:pool-1', master: 'OpaqueRef:master', other_config: {}, uuid: 'pool-1' }
// avoids the pool marking
readOnly = true
status = 'connected'
constructor() {
super()
this.objects = Object.assign(new EventEmitter(), {
all: Object.fromEntries(nextXapiObjects.map(object => [object.$id, object])),
})
xapis.push(this)
}
async connect() {}
async disconnect() {
this.status = 'disconnected'
this.emit('disconnected')
}
getObjectByRef() {
return { uuid: 'host-1' }
}
_interruptOnDisconnect(promise) {
return promise
}
watchEvents() {}
// `lastEventFetchedTimestamp` is old, as after an event fetching error
watchEventsError = new Error('ETIMEDOUT')
}
const xapiModuleUrl = new URL('../xapi/index.mjs', import.meta.url).href
const { default: _, ...xapiNamedExports } = await import(xapiModuleUrl)
mock.module(xapiModuleUrl, { defaultExport: FakeXapi, namedExports: xapiNamedExports })
mock.module(new URL('../xapi-object-to-xo.mjs', import.meta.url).href, {
defaultExport: ({ $id, $ref, $type }) => ({ id: $id, type: $type, $pool: 'pool-1', _xapiRef: $ref }),
})
const { default: XenServers } = await import('./xen-servers.mjs')
const createXenServers = ({ durations = {} } = {}) => {
xapis.length = 0
nextObjectsFetchDuration = 0
nextXapiObjects = [xapiObject('pool', 'pool-1'), xapiObject('VM', 'vm-1'), xapiObject('VM', 'vm-2')]
const xoObjects = new Map()
const resolved = async () => {}
const app = {
addConfigManager() {},
config: {
get: key =>
key === 'xapiOptions'
? { poolMarkingInterval: '1 hour', poolMarkingMaxAge: '1 hour', poolMarkingPrefix: 'xo:' }
: undefined,
getOptional() {},
watchDuration: (key, cb) => cb(durations[key] ?? DELAY),
},
emit() {},
getObject: id => xoObjects.get(id),
hooks: new EventEmitter(),
httpRequest() {},
_objects: {
get: (id, defaultValue) => (xoObjects.has(id) ? xoObjects.get(id) : defaultValue),
set: (id, value) => xoObjects.set(id, value),
unset: id => {
assert(xoObjects.delete(id), `no such object ${id}`)
},
},
_redis: {
del: resolved,
get: async () => null,
keys: async () => [],
sAdd: resolved,
set: resolved,
sMembers: resolved,
},
}
const xenServers = new XenServers(app, { safeMode: true })
app.hooks.emit('core started')
const stored = {
'server-1': {
id: 'server-1',
enabled: true,
host: '192.0.2.1',
password: 'secret',
status: 'disconnected',
username: 'root',
},
}
xenServers._servers = {
first: async id => (stored[id] === undefined ? undefined : { ...stored[id] }),
update: async model => {
Object.assign(stored[model.id], model)
},
}
xenServers._autoReconnectXenServer = () => {}
const lastSeenById = () => Object.fromEntries([...xoObjects].map(([id, object]) => [id, object.$lastSeen]))
const serverStatus = () => stored['server-1'].status
return { lastSeenById, serverStatus, xenServers }
}
// stops the pool marking interval of the connections left open
afterEach(async function () {
for (const xapi of xapis) {
if (xapi.status !== 'disconnected') {
await xapi.disconnect()
}
}
await sleep(DELAY * 2)
})
describe('objects of a lost connection', function () {
it('are flagged after the grace period, and unflagged once events are back', async function () {
const { lastSeenById, xenServers } = createXenServers()
await xenServers.connectXenServer('server-1')
xapis[0].emit('eventFetchingError', new Error('ETIMEDOUT'))
await sleep(DELAY * 1.5)
assert.deepEqual(lastSeenById(), { 'pool-1': LAST_SEEN, 'vm-1': LAST_SEEN, 'vm-2': LAST_SEEN })
xapis[0].emit('eventFetchingSuccess')
assert.deepEqual(lastSeenById(), { 'pool-1': undefined, 'vm-1': undefined, 'vm-2': undefined })
})
it('are unflagged, and the server connected, when refetched before events are back', async function () {
const { lastSeenById, serverStatus, xenServers } = createXenServers()
await xenServers.connectXenServer('server-1')
xapis[0].emit('eventFetchingError', new Error('ECONNRESET'))
await sleep(DELAY * 1.5)
assert.equal(serverStatus(), 'connecting')
// a failover refetches all the objects before fetching events again
xapis[0].objects.emit('update', xapis[0].objects.all)
xapis[0].emit('eventFetchingSuccess')
await sleep(DELAY * 1.5)
assert.equal(serverStatus(), 'connected')
assert.deepEqual(lastSeenById(), { 'pool-1': undefined, 'vm-1': undefined, 'vm-2': undefined })
})
it('are removed when the pool stays unreachable longer than the max age', async function () {
const { lastSeenById, xenServers } = createXenServers()
await xenServers.connectXenServer('server-1')
xapis[0].emit('eventFetchingError', new Error('ETIMEDOUT'))
await sleep(DELAY * 2.5)
assert.deepEqual(lastSeenById(), {})
// the connection recovers from scratch
xapis[0].emit('eventFetchingSuccess')
assert.deepEqual(lastSeenById(), { 'pool-1': undefined, 'vm-1': undefined, 'vm-2': undefined })
})
it('are kept on an unexpected disconnection, then reconciled by the next connection', async function () {
const { lastSeenById, xenServers } = createXenServers()
await xenServers.connectXenServer('server-1')
// e.g. `SESSION_INVALID` after a toolstack restart
await xapis[0].disconnect()
await sleep(10)
assert.deepEqual(lastSeenById(), { 'pool-1': LAST_SEEN, 'vm-1': LAST_SEEN, 'vm-2': LAST_SEEN })
await assert.rejects(
async () => xenServers.getXenServerIdByObject('vm-1'),
/the pool of object vm-1 is disconnected, last seen at /
)
// `vm-2` has been destroyed in the meantime
nextXapiObjects = [xapiObject('pool', 'pool-1'), xapiObject('VM', 'vm-1')]
await xenServers.connectXenServer('server-1', { enable: false })
assert.deepEqual(lastSeenById(), { 'pool-1': undefined, 'vm-1': undefined })
assert.equal(xenServers.getXenServerIdByObject('vm-1'), 'server-1')
})
it('are kept, flagged, when an attempt to reconnect fails after fetching some of them', async function () {
const { lastSeenById, xenServers } = createXenServers({
durations: { xapiObjectsFetchTimeout: DELAY, xapiStaleObjectsMaxAge: DELAY * 20 },
})
await xenServers.connectXenServer('server-1')
await xapis[0].disconnect()
await sleep(10)
// the next attempt provides part of the objects, then times out
nextXapiObjects = [xapiObject('pool', 'pool-1'), xapiObject('VM', 'vm-1')]
nextObjectsFetchDuration = DELAY * 2
await assert.rejects(xenServers.connectXenServer('server-1'), {
data: { objectId: 'server-1', code: 'TIMEOUT_CONNECT_SERVER' },
})
assert.deepEqual(lastSeenById(), { 'pool-1': LAST_SEEN, 'vm-1': LAST_SEEN, 'vm-2': LAST_SEEN })
// and the next connection reconciles all of them
nextXapiObjects = [xapiObject('pool', 'pool-1'), xapiObject('VM', 'vm-1'), xapiObject('VM', 'vm-2')]
nextObjectsFetchDuration = 0
await xenServers.connectXenServer('server-1')
assert.deepEqual(lastSeenById(), { 'pool-1': undefined, 'vm-1': undefined, 'vm-2': undefined })
})
it('are removed when a first connection fails after fetching some of them', async function () {
const { lastSeenById, xenServers } = createXenServers({ durations: { xapiObjectsFetchTimeout: DELAY } })
nextObjectsFetchDuration = DELAY * 2
await assert.rejects(xenServers.connectXenServer('server-1'), {
data: { objectId: 'server-1', code: 'TIMEOUT_CONNECT_SERVER' },
})
assert.deepEqual(lastSeenById(), {})
})
it('are removed after the max age when the server does not reconnect', async function () {
const { lastSeenById, xenServers } = createXenServers()
await xenServers.connectXenServer('server-1')
await xapis[0].disconnect()
await sleep(DELAY * 1.5)
assert.deepEqual(lastSeenById(), {})
})
it('are removed on a deliberate disconnection', async function () {
const { lastSeenById, xenServers } = createXenServers()
await xenServers.connectXenServer('server-1')
await xenServers.disconnectXenServer('server-1')
assert.deepEqual(lastSeenById(), {})
})
it('are removed on a deliberate disconnection of a server already lost', async function () {
const { lastSeenById, xenServers } = createXenServers()
await xenServers.connectXenServer('server-1')
await xapis[0].disconnect()
await sleep(10)
await xenServers.disconnectXenServer('server-1')
assert.deepEqual(lastSeenById(), {})
})
})
describe('objects fetch on connection', function () {
it('waits for the objects up to their own timeout, not the disconnection delay', async function () {
const { lastSeenById, xenServers } = createXenServers({ durations: { xapiObjectsFetchTimeout: DELAY * 5 } })
nextObjectsFetchDuration = DELAY * 2
await xenServers.connectXenServer('server-1')
assert.deepEqual(lastSeenById(), { 'pool-1': undefined, 'vm-1': undefined, 'vm-2': undefined })
})
it('fails when the objects take longer than their timeout', async function () {
const { xenServers } = createXenServers({
durations: { xapiMarkDisconnectedDelay: DELAY * 5, xapiObjectsFetchTimeout: DELAY },
})
nextObjectsFetchDuration = DELAY * 2
await assert.rejects(xenServers.connectXenServer('server-1'), {
data: { objectId: 'server-1', code: 'TIMEOUT_CONNECT_SERVER' },
})
})
})
describe('server error', function () {
it('is not reported while a quiet pool returns no events', async function () {
const { xenServers } = createXenServers()
await xenServers.connectXenServer('server-1')
// `event.from` may wait longer than `xapiMarkDisconnectedDelay`
xapis[0].watchEventsError = undefined
const server = await xenServers.getXenServer('server-1')
assert.equal(server.error, undefined)
})
})

View File

@@ -0,0 +1,43 @@
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 => {
// the pool of a stale SR is disconnected: it cannot receive anything
if (sr.$lastSeen === undefined && 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,156 @@
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('never returns an SR of a disconnected pool', () => {
const srs = [sr({ uuid: 'live' }), { ...sr({ uuid: 'stale' }), $lastSeen: Date.now() }]
assert.deepEqual(resolveBackupSrs({ srs: 'sr:type:nfs' }, srs, RUN_CONTEXT), ['live'])
})
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

@@ -52,6 +52,9 @@ export default class XenServers {
this._connectingXenServers = new Set()
this._reconnectingXenServers = new Set()
this._serverIdsByPool = { __proto__: null }
// objects kept, flagged with `$lastSeen`, after the loss of the connection
// which provided them, by server id: `{ timeout, xoIds }`
this._staleObjects = { __proto__: null }
this._stats = new XapiStats()
this._xapis = { __proto__: null }
this._app = app
@@ -59,6 +62,12 @@ export default class XenServers {
app.config.watchDuration('xapiMarkDisconnectedDelay', xapiMarkDisconnectedDelay => {
this._xapiMarkDisconnectedDelay = xapiMarkDisconnectedDelay
})
app.config.watchDuration('xapiObjectsFetchTimeout', xapiObjectsFetchTimeout => {
this._xapiObjectsFetchTimeout = xapiObjectsFetchTimeout
})
app.config.watchDuration('xapiStaleObjectsMaxAge', xapiStaleObjectsMaxAge => {
this._xapiStaleObjectsMaxAge = xapiStaleObjectsMaxAge
})
app.hooks.on('clean', () => this._servers.rebuildIndexes())
@@ -283,6 +292,11 @@ export default class XenServers {
const serverId = this._serverIdsByPool[poolId]
if (serverId === undefined) {
if (object.$lastSeen !== undefined) {
throw new Error(
`the pool of object ${object.id} is disconnected, last seen at ${new Date(object.$lastSeen).toISOString()}`
)
}
throw new Error(`no connection found for object ${object.id}`)
}
return serverId
@@ -339,7 +353,8 @@ export default class XenServers {
xapiIdsToXo[xapiId] = xoId
const previous = objects.get(xoId, undefined)
if (previous && previous._xapiRef !== $ref) {
// a stale object is replaced by the one of the new connection
if (previous && previous._xapiRef !== $ref && previous.$lastSeen === undefined) {
const conflicts_ = conflicts[xoId] || (conflicts[xoId] = { __proto__: null })
conflicts_[conId] = xoObject
} else {
@@ -395,6 +410,39 @@ export default class XenServers {
})
}
// keeps the (already flagged) objects of a lost connection until the server
// reconnects, or at most `xapiStaleObjectsMaxAge`
_keepStaleObjects(serverId, xoIds) {
let stale = this._staleObjects[serverId]
if (stale === undefined) {
stale = this._staleObjects[serverId] = {
timeout: setTimeout(() => this._purgeStaleObjects(serverId), this._xapiStaleObjectsMaxAge),
xoIds: new Set(),
}
}
for (const xoId of xoIds) {
stale.xoIds.add(xoId)
}
}
// removes the objects of a lost connection which have not been provided again
// since, i.e. which are still flagged
_purgeStaleObjects(serverId) {
const stale = this._staleObjects[serverId]
if (stale === undefined) {
return
}
delete this._staleObjects[serverId]
clearTimeout(stale.timeout)
const objects = this._app._objects
for (const xoId of stale.xoIds) {
if (objects.get(xoId, undefined)?.$lastSeen !== undefined) {
objects.unset(xoId)
}
}
}
// starts a reconnection loop for a server left `enabled` but without a
// Xapi instance, a state nothing else retries (see _xenServerAutoReconnect.mjs)
_autoReconnectXenServer(id) {
@@ -477,6 +525,11 @@ export default class XenServers {
watchEvents: false,
}))
// set by a failed attempt to reconnect a lost server, whose objects are kept
// flagged: the objects this attempt has fetched so far replaced some of them,
// and must be kept, flagged, like the others, not removed
let keepObjectsOnFailure = false
try {
await xapi.connect()
@@ -589,17 +642,40 @@ export default class XenServers {
markPool()
markPoolHandle = setInterval(markPool, poolMarkingInterval)
},
uninstall() {
// stops following the XAPI objects, without removing the XO ones
detach() {
clearInterval(markPoolHandle)
objects.removeListener('add', onAddOrUpdate)
objects.removeListener('update', onAddOrUpdate)
objects.removeListener('remove', onRemove)
objects.removeListener('finish', onFinish)
},
uninstall() {
this.detach()
onRemove(objects.all)
},
getXoIds: () => Object.values(xapiIdsToXo),
// keeps the XO objects but flags them with the last time the pool
// was successfully reached, instead of removing them
markStale: () => {
const lastSeen = xapi.lastEventFetchedTimestamp ?? Date.now()
const xoObjects = this._app._objects
for (const xoId of Object.values(xapiIdsToXo)) {
const object = xoObjects.get(xoId, undefined)
if (object !== undefined && object.$lastSeen === undefined) {
xoObjects.set(xoId, { ...object, $lastSeen: lastSeen })
}
}
},
// replays the current XAPI objects, which replaces the flagged ones
refresh() {
onAddOrUpdate(objects.all)
},
addObject,
getData: (id, key) => {
const value = (typeof id === 'string' ? xapi.getObject(id) : id).other_config[`xo:${camelToSnakeCase(key)}`]
@@ -636,9 +712,22 @@ export default class XenServers {
// this server, or stop the auto-reconnect loop which waits for
// `disconnected`
let markDisconnectedTimeout
// after the grace period, the objects are kept but flagged as stale, then
// removed once `xapiStaleObjectsMaxAge` is reached
let removeStaleTimeout
// the pool cannot be used to know whether it has been marked: the
// listeners are still installed and a refetch of the objects (e.g. after
// a failover) registers it again
let marked = false
let staleRemoved = false
const onEventFetchingError = () => {
markDisconnectedTimeout = setTimeout(() => {
xapi.xo.uninstall()
marked = true
xapi.xo.markStale()
removeStaleTimeout = setTimeout(() => {
xapi.xo.uninstall()
staleRemoved = true
}, this._xapiStaleObjectsMaxAge)
// switch server status from connected to connecting
this._forgetXenServerPool(server.id)
@@ -646,11 +735,19 @@ export default class XenServers {
}, this._xapiMarkDisconnectedDelay)
xapi.once('eventFetchingSuccess', () => {
xapi.once('eventFetchingError', onEventFetchingError)
if (serverIdsByPool[poolId] === undefined) {
// the pool may now be known under another identifier, `install()`
// replays the pool object which registers it under the right one
if (marked) {
marked = false
clearTimeout(removeStaleTimeout)
// the pool may now be known under another identifier, replaying
// the pool object registers it under the right one
serverIdsByPool[poolId] = server.id
xapi.xo.install()
if (staleRemoved) {
staleRemoved = false
xapi.xo.install()
} else {
xapi.xo.refresh()
}
_updateXenServer(server.id, { error: null, status: 'connected' })
} else {
clearTimeout(markDisconnectedTimeout)
@@ -661,7 +758,20 @@ export default class XenServers {
xapi.once('disconnected', async () => {
clearTimeout(markDisconnectedTimeout)
xapi.xo.uninstall()
clearTimeout(removeStaleTimeout)
// `disconnectXenServer` and failed connections forget the Xapi
// beforehand: deliberate disconnections and failed first connections
// remove the objects, the others, including a failed attempt to
// reconnect a lost server, keep them, flagged, until the server
// reconnects
if (this._xapis[server.id] === xapi || keepObjectsOnFailure) {
xapi.xo.markStale()
xapi.xo.detach()
this._keepStaleObjects(server.id, xapi.xo.getXoIds())
} else {
xapi.xo.uninstall()
}
delete this._xapis[server.id]
this._forgetXenServerPool(server.id)
this._app.emit('server:disconnected', { server, xapi })
@@ -676,18 +786,22 @@ export default class XenServers {
})
try {
await timeout.call(xapi._interruptOnDisconnect(xapi.objectsFetched), this._xapiMarkDisconnectedDelay)
await timeout.call(xapi._interruptOnDisconnect(xapi.objectsFetched), this._xapiObjectsFetchTimeout)
} catch (error) {
if (!(error instanceof TimeoutError)) {
throw error
}
log.warn('objects take too long to fetch', { id, xapiMarkDisconnectedDelay: this._xapiMarkDisconnectedDelay })
log.warn('objects take too long to fetch', { id, xapiObjectsFetchTimeout: this._xapiObjectsFetchTimeout })
throw operationFailed({ objectId: id, code: 'TIMEOUT_CONNECT_SERVER' })
}
// objects of the previous connection which no longer exist
this._purgeStaleObjects(server.id)
this._app.emit('server:connected', { server, xapi })
await this.updateXenServer(id, { error: null, status: 'connected' })::ignoreErrors()
} catch (error) {
keepObjectsOnFailure = this._staleObjects[server.id] !== undefined
delete this._xapis[server.id]
await xapi.disconnect()::ignoreErrors()
@@ -759,6 +873,7 @@ export default class XenServers {
this._forgetXenServerPool(id)
await xapi?.disconnect()
this._purgeStaleObjects(id)
if (status !== 'disconnected') {
await this.updateXenServer(id, { status: 'disconnected' })
}
@@ -783,7 +898,9 @@ export default class XenServers {
const xapis = this._xapis
const lastEventFetchedTimestamp = xapis[server.id]?.lastEventFetchedTimestamp
// a quiet pool may not return events for longer than this delay
if (
xapis[server.id]?.watchEventsError !== undefined &&
lastEventFetchedTimestamp !== undefined &&
Date.now() > lastEventFetchedTimestamp + this._xapiMarkDisconnectedDelay &&
xapis[server.id].watchEventsError !== undefined

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