Compare commits

...

5 Commits

Author SHA1 Message Date
Olivier Lambert
1d08cdcd8b Merge branch 'master' into feat/rest-api-vm-eject-cd 2026-11-05 09:35:43 +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
93ce1d783a chore: changelog PR number 2026-10-22 18:48:30 +02:00
Olivier Lambert
f7fb88cf80 feat(rest-api): add vms/:id/actions/eject_cd route
Equivalent of the `vm.ejectCd` JSON-RPC method, protected by the new `eject-cd` VM ACL action.
2026-10-22 18:28:14 +02:00
43 changed files with 1310 additions and 237 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

@@ -214,6 +214,7 @@ export interface Xapi {
joinPool(masterAddress: string, masterUsername: string, masterPassword: string, force?: boolean): Promise<void>
listMissingPatches(host: XoHost['id']): Promise<XcpPatches[] | XsPatches[]>
pool_emergencyShutdown(): Promise<void>
ejectCdFromVm(vmId: XoVm['id']): Promise<void>
resumeVm(id: XoVm['id']): Promise<void>
revertVm(snapshotId: XoVmSnapshot['id']): Promise<void>
unpauseVm(id: XoVm['id']): Promise<void>

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

@@ -1,6 +1,7 @@
export default {
clone: true,
delete: true,
'eject-cd': true,
export: true,
'migrate-send': true,
pause: true,

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

@@ -637,6 +637,47 @@ export class VmController extends XapiXoController<XoVm> {
})
}
/**
* Eject the CD currently inserted in the VM's CD drive.
*
* Does nothing if the VM has no CD drive or if the drive is already empty.
*
* Required privilege:
* - resource: vm, action: eject-cd
*
* @example id "f07ab729-c0e8-721c-45ec-f11276377030"
*/
@Example(taskLocation)
@Extension('x-mcp-exposure', 'confirm')
@Post('{id}/actions/eject_cd')
@Middlewares(acl({ resource: 'vm', action: 'eject-cd', objectId: 'params.id' }))
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
async ejectCdVm(@Path() id: string, @Query() sync?: boolean): CreateActionReturnType<void> {
const vmId = id as XoVm['id']
const action = async () => {
await this.getXapi(vmId)
.ejectCdFromVm(vmId)
.catch(error => {
if (error.code !== 'VBD_IS_EMPTY') {
throw error
}
})
}
return this.createAction<void>(action, {
sync,
statusCode: noContentResp.status,
taskProperties: {
name: 'eject CD from VM',
objectId: vmId,
},
})
}
/**
* Required privilege:
* - resource: vm, action: revert-snapshot

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,8 @@
> Users must be able to say: "Nice enhancement, I'm eager to test it"
- [REST API] Add `POST /rest/v0/vms/{id}/actions/eject_cd` to eject the CD from a VM (PR [#10660](https://github.com/vatesfr/xen-orchestra/pull/10660))
### Bug fixes
> Users must be able to say: "I had this issue, happy to know it's fixed"
@@ -31,6 +33,14 @@
<!--packages-start-->
- @vates/types minor
- @xen-orchestra/acl minor
- @xen-orchestra/backups minor
- @xen-orchestra/qa-test patch
- @xen-orchestra/rest-api minor
- @xen-orchestra/web patch
- @xen-orchestra/web-core minor
- xo-server minor
- 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

@@ -151,7 +151,7 @@ Actions are written using the exact string you pass in a privilege. A parent act
| Resource | Available actions |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `vm` | `read`, `clone`, `delete`, `migrate-send`, `export`, `pause`, `start`, `resume`, `revert-snapshot`, `snapshot`, `suspend`, `unpause`, `reboot:clean`, `reboot:hard`, `shutdown:clean`, `shutdown:hard`, `update:affinityHost`, `update:autoPoweron`, `update:blockedOperations`, `update:coresPerSocket`, `update:cpuCap`, `update:cpuMask`, `update:cpuWeight`, `update:cpus`, `update:cpusStaticMax`, `update:creation`, `update:datasources`, `update:expNestedHvm`, `update:hasVendorDevice`, `update:highAvailability`, `update:hvmBootFirmware`, `update:memory`, `update:memoryMax`, `update:memoryMin`, `update:memoryStaticMax`, `update:nameDescription`, `update:nameLabel`, `update:nestedVirt`, `update:nicType`, `update:notes`, `update:PV_args`, `update:resourceSet`, `update:secureBoot`, `update:share`, `update:startDelay`, `update:suspendSr`, `update:tags`, `update:uefiMode`, `update:vga`, `update:videoram`, `update:viridian`, `update:virtualizationMode`, `update:xenStoreData` |
| `vm` | `read`, `clone`, `delete`, `eject-cd`, `migrate-send`, `export`, `pause`, `start`, `resume`, `revert-snapshot`, `snapshot`, `suspend`, `unpause`, `reboot:clean`, `reboot:hard`, `shutdown:clean`, `shutdown:hard`, `update:affinityHost`, `update:autoPoweron`, `update:blockedOperations`, `update:coresPerSocket`, `update:cpuCap`, `update:cpuMask`, `update:cpuWeight`, `update:cpus`, `update:cpusStaticMax`, `update:creation`, `update:datasources`, `update:expNestedHvm`, `update:hasVendorDevice`, `update:highAvailability`, `update:hvmBootFirmware`, `update:memory`, `update:memoryMax`, `update:memoryMin`, `update:memoryStaticMax`, `update:nameDescription`, `update:nameLabel`, `update:nestedVirt`, `update:nicType`, `update:notes`, `update:PV_args`, `update:resourceSet`, `update:secureBoot`, `update:share`, `update:startDelay`, `update:suspendSr`, `update:tags`, `update:uefiMode`, `update:vga`, `update:videoram`, `update:viridian`, `update:virtualizationMode`, `update:xenStoreData` |
| `vm-snapshot` | `read`, `delete`, `export`, `update:tags` |
| `vm-template` | `read`, `delete`, `export`, `instantiate`, `update:tags` |
| `vm-controller` | `read`, `update:tags` |

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

@@ -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