Compare commits

...

5 Commits

Author SHA1 Message Date
Olivier Lambert
41fb9b4d65 Merge branch 'master' into fix/sdn-controller-plugin-error 2026-11-05 09:15:07 +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
Olivier Lambert
8ee4cb92c9 chore(xo-server-sdn-controller): link the changelog entry to its PR 2026-10-30 12:05:27 +00:00
Olivier Lambert
10594b5676 fix(xo-server-sdn-controller): say which host failed a traffic rule with the XAPI plugin
With the XAPI plugin (`useDirectChannel = false`), a traffic rule is
applied by calling `sdncontroller.py` on every host of the network, through
`asyncEach` with `stopOnError: false`. When a host fails, `asyncEach`
rejects with an `AggregateError` that has no message, so the error the user
gets is empty: `{"error": ""}` from the REST API, an empty message in XO 6,
and no useful text in XO 5.

Each host's error is now rethrown with the host's name, keeping the
original as its `cause` (enumerable, so that the XAPI error's code and
params are still serialized, as in the JSON-RPC error's data), and the
`AggregateError` gets these messages, e.g.
`XENAPI_MISSING_PLUGIN(sdncontroller.py) on host-2`; the errors stay in
`errors`. VIF and network rules, added or deleted, share this path.

Test: a rule with the plugin missing on one of two hosts (message, cause,
serialized details), on three hosts with two failing, and a successful one
(the first three fail without the fix).
2026-10-30 10:58:30 +00:00
41 changed files with 1366 additions and 244 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

@@ -15,6 +15,8 @@
> Users must be able to say: "I had this issue, happy to know it's fixed"
- [SDN Controller] With the XAPI plugin, a traffic rule a host fails to apply or remove reports which host failed and why (e.g. `XENAPI_MISSING_PLUGIN(sdncontroller.py) on host-2`) instead of an empty error (PR [#10693](https://github.com/vatesfr/xen-orchestra/pull/10693))
### Packages to release
> When modifying a package, add it here with its release type.
@@ -31,6 +33,14 @@
<!--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-sdn-controller 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

@@ -13,14 +13,32 @@ export class OpenFlowPlugin {
async #callPluginOnAllNetwork(network, method, parameters) {
const bridge = this.#getBridge(network)
return asyncEach(
network.$PIFs,
async PIF => {
const host = PIF.$host
return host.$xapi.call('host.call_plugin', host.$ref, PLUGIN_NAME, method, { ...parameters, bridge })
},
{ stopOnError: false }
)
try {
return await asyncEach(
network.$PIFs,
async PIF => {
const host = PIF.$host
try {
return await host.$xapi.call('host.call_plugin', host.$ref, PLUGIN_NAME, method, { ...parameters, bridge })
} catch (error) {
const hostError = new Error(`${error.message} on ${host.name_label}`)
// assigned, not passed as an option, to be enumerable: the XAPI
// error's details (code, params) are then serialized with it, as
// in the JSON-RPC error's data
hostError.cause = error
throw hostError
}
},
{ stopOnError: false }
)
} catch (error) {
// asyncEach's AggregateError has no message: say which hosts failed and
// why, the original errors stay in `errors[].cause`
if (Array.isArray(error.errors)) {
error.message = error.errors.map(_ => _.message).join(', ')
}
throw error
}
}
async addRule({ vif, allow, protocol, ipRange, direction, port, priority }) {

View File

@@ -0,0 +1,74 @@
import { describe, it } from 'node:test'
import { strict as assert } from 'node:assert'
import { OpenFlowPlugin } from './openflow-plugin.js'
// a XAPI error, as xen-api's XapiError: the message is `CODE(params)`
function xapiError(code, params) {
const error = new Error(`${code}(${params.join(', ')})`)
error.code = code
error.params = params
return error
}
function fakeHost(name, error) {
const calls = []
const host = {
$ref: `ref-${name}`,
name_label: name,
$xapi: {
async call(...args) {
calls.push(args)
if (error !== undefined) {
throw error
}
return '{"returncode": 0}'
},
},
}
return { host, calls }
}
describe('OpenFlowPlugin', () => {
const missingPlugin = xapiError('XENAPI_MISSING_PLUGIN', ['sdncontroller.py'])
const host1 = fakeHost('host-1')
const host2 = fakeHost('host-2', missingPlugin)
const network = { bridge: 'xenbr0', $PIFs: [{ $host: host1.host }, { $host: host2.host }] }
const vif = { MAC: '02:00:00:00:00:01', $network: network }
const rule = { allow: false, protocol: 'TCP', ipRange: '192.0.2.10', direction: 'from/to', port: 22 }
it('says which host failed a VIF rule, and why', async () => {
await assert.rejects(new OpenFlowPlugin().addRule({ vif, ...rule }), error => {
assert.equal(error.message, 'XENAPI_MISSING_PLUGIN(sdncontroller.py) on host-2')
assert.equal(error.errors.length, 1)
assert.equal(error.errors[0].cause, missingPlugin)
// the XAPI error's details are serialized with it
assert.deepEqual(JSON.parse(JSON.stringify(error.errors[0])).cause, {
code: 'XENAPI_MISSING_PLUGIN',
params: ['sdncontroller.py'],
})
return true
})
assert.equal(host1.calls.length, 1)
assert.equal(host2.calls.length, 1)
})
it('says which host failed a network rule, and why', async () => {
await assert.rejects(new OpenFlowPlugin().deleteNetworkRule({ network, ...rule, cookie: '0x1' }), {
message: 'XENAPI_MISSING_PLUGIN(sdncontroller.py) on host-2',
})
})
it('lists every host that failed', async () => {
const other = fakeHost('host-3', new Error('connect ETIMEDOUT'))
const network3 = { bridge: 'xenbr0', $PIFs: [...network.$PIFs, { $host: other.host }] }
await assert.rejects(new OpenFlowPlugin().addRule({ vif: { ...vif, $network: network3 }, ...rule }), {
message: 'XENAPI_MISSING_PLUGIN(sdncontroller.py) on host-2, connect ETIMEDOUT on host-3',
})
})
it('succeeds when every host applies the rule', async () => {
const ok = { bridge: 'xenbr0', $PIFs: [{ $host: fakeHost('host-1').host }] }
await new OpenFlowPlugin().addRule({ vif: { ...vif, $network: ok }, ...rule })
})
})

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