Compare commits

...

11 Commits

Author SHA1 Message Date
Olivier Lambert
c23816982d chore: link the changelog entries to #10729 2026-11-06 14:23:47 +01:00
Olivier Lambert
cadf27ce64 feat(web,xo-web): live vCPU scaling in XO 6
- pool System page: a Live vCPU scaling row showing the maximum new VMs
  actually get (capped by the smallest host, with the reason), edited in a
  drawer that calls PATCH /pools/{id}
- new VM form: the vCPUs field says up to how many vCPUs the VM can add while
  it runs, that Windows VMs only change while halted, or that the maximum is
  reached
- the cap logic mirrors xo-server/src/xapi/_vcpuHeadroom.mjs, with unit tests
- XO 5: name the distributions that bring added vCPUs online
2026-11-06 14:07:39 +01:00
Olivier Lambert
ec6f052a3e feat(rest-api): PATCH /pools/{id} for live vCPU scaling
Sets vcpusMaxHeadroom (2 to 128, null turns it off) through the same pool
setter as JSON-RPC pool.set. New ACL action pool update:vcpusMaxHeadroom.
2026-11-06 13:49:08 +01:00
Olivier Lambert
26a57e2c4e fix(xo-server,xo-web): live vCPU scaling review fixes
- the XO 5 new VM form always sends cpusMax = max(vCPUs, template max): only
  a maximum above that is the user's choice, otherwise the feature never
  applied to VMs created from XO 5
- cap on the physical cores of the smallest host rather than its threads, so
  VMs keep starting if SMT is turned off (XAPI compares VCPUs_max with
  cpu_count, which halves)
- refuse lowering the vCPUs of a running Windows VM before any setter runs
  (new validate hook in makeEditObject), and also detect Windows from the
  guest tools
- one pool setter shared by JSON-RPC and REST
- XO 5: reject non-numbers instead of turning the feature off, show the
  memory note and the Debian caveat
- changelog: created from a template, capped at creation, DR caveat
2026-11-06 13:46:45 +01:00
Olivier Lambert
3a04ed65f9 feat(xo-server,xo-web): opt-in live vCPU scaling for new VMs
A pool can set xo:vcpusMaxHeadroom (pool.set vcpusMaxHeadroom, 2 to 128).
VMs created by XO then get that vCPU maximum, capped by the thread count of
the smallest host, the Xen HVM limit and the CPU topology, so vCPUs can be
added later while the VM runs. Windows VMs and VMs created with an explicit
maximum are left alone. Lowering the vCPUs of a running Windows VM is refused:
Windows keeps running them while XAPI reports the lower count.
2026-11-06 13:35:44 +01:00
Joris K
9c6a59bb89 feat(web-core): show UiButton loader on the right of the label (#10723) 2026-11-06 12:57:52 +01:00
Thomas Moraine
12aa9576b9 docs: use US spelling for licensing (#10666) 2026-11-06 10:21:31 +01:00
Joris K
85541ce5d7 fix(xo6): Fix some issue in the pool tab (#10714) 2026-11-05 15:42:09 +01:00
Joris K
56a33c1639 fix(xo6): change padding tasks (#10713) 2026-11-05 14:49:03 +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
80 changed files with 2302 additions and 287 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

@@ -335,6 +335,8 @@ export interface Xapi {
checkLimits?: (limits: Record<string, number>, vm: XenApiVmWrapped) => Promise<void>
): Promise<void>
editVif(id: XoVif['id'], props: EditVifProps): Promise<void>
/** vCPU maximum given to the VMs XO creates in this pool (2 to 128), null turns it off */
setVcpusMaxHeadroom(value: number | null): Promise<void>
startVm(
id: XoVm['id'],
opts?: {

View File

@@ -479,6 +479,8 @@ export type XoPool = BaseXapiXo & {
suspendSr?: XoSr['id']
tags: string[]
type: 'pool'
/** vCPU maximum given to the VMs XO creates in this pool, undefined when the pool did not opt in */
vcpusMaxHeadroom?: number
vtpmSupported: boolean
zstdSupported: boolean
}
@@ -552,8 +554,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 +566,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 +600,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

@@ -10,5 +10,6 @@ export default {
'rolling-update': true,
update: {
tags: true,
vcpusMaxHeadroom: 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

@@ -5,6 +5,7 @@ import {
Extension,
Get,
Middlewares,
Patch,
Path,
Post,
Put,
@@ -23,7 +24,7 @@ import { provide } from 'inversify-binding-decorators'
import { json, type Request as ExRequest, type Response as ExResponse } from 'express'
import type { VatesTask } from '@vates/types/lib/vates/task'
import { acl } from '../middlewares/acl.middleware.mjs'
import { acl, actionsFromBody } from '../middlewares/acl.middleware.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import {
asynchronousActionResp,
@@ -86,6 +87,7 @@ import type {
PoolDashboard,
RollingPoolActionBody,
RollingPoolUpdateBody,
UpdatePoolRequestBody,
} from './pool.type.mjs'
import { partialTasks, taskIds, taskLocation } from '../open-api/oa-examples/task.oa-example.mjs'
import { createNetwork } from '../open-api/oa-examples/schedule.oa-example.mjs'
@@ -826,6 +828,37 @@ export class PoolController extends XapiXoController<XoPool> {
})
}
/**
* Partial update of a pool. Only the fields present in the body are modified.
*
* Required privilege per field provided in the body:
* - resource: pool, action: update:&lt;field&gt; (e.g. update:vcpusMaxHeadroom)
*
* `vcpusMaxHeadroom`: vCPU maximum given to the VMs XO creates in this pool, so vCPUs can be added while they
* run (`null` turns it off). Capped at creation by the thread count of the smallest host; not applied to
* Windows VMs.
*
* @example id "355ee47d-ff4c-4924-3db2-fd86ae629676"
* @example body { "vcpusMaxHeadroom": 16 }
*/
@Extension('x-mcp-exposure', 'confirm')
@Patch('{id}')
@Middlewares([
json(),
acl({
resource: 'pool',
actions: actionsFromBody(['update:vcpusMaxHeadroom']),
objectId: 'params.id',
}),
])
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(invalidParametersResp.status, invalidParametersResp.description)
async updatePool(@Path() id: string, @Body() body: UpdatePoolRequestBody): Promise<void> {
await this.#poolService.updatePool(id as XoPool['id'], body)
}
/**
* Required privilege:
* - resource: pool, action: update:tags

View File

@@ -16,7 +16,7 @@ import { VmService } from '../vms/vm.service.mjs'
import { AlarmService } from '../alarms/alarm.service.mjs'
import { getTopPerProperty, isSrWritable, promiseWriteInStream } from '../helpers/utils.helper.mjs'
import { type AsyncCacheEntry, getFromAsyncCache } from '../helpers/cache.helper.mjs'
import type { PoolDashboard } from './pool.type.mjs'
import type { PoolDashboard, UpdatePoolRequestBody } from './pool.type.mjs'
import { MissingPatchesInfo } from '../hosts/host.type.mjs'
import type { HasNoAuthorization } from '../rest-api/rest-api.type.mjs'
@@ -46,6 +46,14 @@ export class PoolService {
expiresIn: this.#restApi.xoApp.config.getOptionalDuration('rest-api.dashboardCacheExpiresIn'),
}
}
async updatePool(id: XoPool['id'], { vcpusMaxHeadroom }: UpdatePoolRequestBody): Promise<void> {
// Touch the object so 404 is raised before any side effect.
void this.#restApi.getObject<XoPool>(id, 'pool')
if (vcpusMaxHeadroom !== undefined) {
await this.#restApi.xoApp.getXapi(id).setVcpusMaxHeadroom(vcpusMaxHeadroom)
}
}
#getHostsStatus(poolId: XoPool['id']): PoolDashboard['hosts']['status'] {
const { running, disabled, halted, total } = this.#hostService.getHostsStatus({

View File

@@ -165,3 +165,14 @@ export type RollingPoolUpdateBody = RollingPoolActionBody & {
*/
acceptCurrentStateAsBaseline?: boolean
}
export interface UpdatePoolRequestBody {
/**
* vCPU maximum given to the VMs XO creates in this pool, `null` turns it off
*
* @isInt
* @minimum 2
* @maximum 128
*/
vcpusMaxHeadroom?: number | null
}

View File

@@ -1,14 +1,16 @@
<!-- v7 -->
<template>
<button :class="classNames" :disabled="busy || isDisabled || lockIcon" class="ui-button" type="button">
<VtsIcon :busy :name="leftIcon" class="icon" size="current" />
<VtsIcon :name="leftIcon" class="icon" size="current" />
<slot />
<UiLoader v-if="busy" class="icon" />
<VtsIcon v-if="lockIcon" name="fa:lock" size="medium" class="icon" />
</button>
</template>
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import UiLoader from '@core/components/ui/loader/UiLoader.vue'
import { useDisabled } from '@core/composables/disabled.composable.ts'
import type { IconName } from '@core/icons'
import { toVariants } from '@core/utils/to-variants.util.ts'

View File

@@ -61,7 +61,7 @@ const typoClasses = {
medium: 'typo-body-bold',
}
const isLink = computed(() => to !== undefined || href !== undefined)
const isLink = computed(() => to !== undefined || href !== undefined || disabled)
const counterProps = computed(() => {
if (counter === undefined) {

View File

@@ -162,7 +162,6 @@ const progress = computed(() => {
--line-width: 0.1rem;
display: flex;
align-items: center;
padding-left: 1.6rem;
gap: 0.4rem;
.tree-lines {
@@ -195,7 +194,7 @@ const progress = computed(() => {
flex: 1;
min-width: 0;
gap: 1.6rem;
padding: 0.4rem 1.6rem;
padding: 0.4rem 1.6rem 0.4rem 0.8rem;
justify-content: space-between;
align-items: center;
}

View File

@@ -792,6 +792,14 @@
"license-socket": "License socket",
"license-type": "License type",
"licensing": "Licensing",
"live-vcpu-scaling": "Live vCPU scaling",
"live-vcpu-scaling:capped": "Limited to {n} on this pool: the physical cores of its smallest host",
"live-vcpu-scaling:capped-by-host": "Set to {set}, limited by the smallest host",
"live-vcpu-scaling:description": "VMs created from a template in this pool get vCPUs that can be added while they run, up to this number. Existing VMs and Windows VMs are not changed.",
"live-vcpu-scaling:guests": "Linux guests must bring added vCPUs online: Ubuntu, Rocky and AlmaLinux do, Debian needs a udev rule.",
"live-vcpu-scaling:invalid": "The maximum must be a whole number from 2 to 128",
"live-vcpu-scaling:memory": "Each possible vCPU reserves about 1 MiB of host memory per VM.",
"live-vcpu-scaling:up-to": "Up to {n} vCPUs",
"load-at-startup": "Load at startup",
"load-average": "Load average",
"loaded": "Loaded",
@@ -964,6 +972,9 @@
"new-vm:user-config-variables-escape": "Escape any variable with a preceding backslash '\\'",
"new-vm:user-config-variables-index": "{'{index}'}: the VM's index, it will take 0 in case of single VM",
"new-vm:user-config-variables-name": "{'{name}'}: the VM's name. It must not contain '_'",
"new-vm:vcpus-at-max": "Live vCPU scaling stops at {n} on this pool: adding vCPUs later will need a shutdown",
"new-vm:vcpus-live-up-to": "vCPUs can be added while the VM runs, up to {n}",
"new-vm:vcpus-windows-halted": "Windows: vCPUs can only be changed while the VM is halted",
"news": "News",
"news-name": "{name} news",
"next-run": "Next run",

View File

@@ -792,6 +792,14 @@
"license-socket": "Licence socket",
"license-type": "Type de licence",
"licensing": "Licences",
"live-vcpu-scaling": "Ajout de vCPU à chaud",
"live-vcpu-scaling:capped": "Limité à {n} sur ce pool : les cœurs physiques de son plus petit hôte",
"live-vcpu-scaling:capped-by-host": "Réglé sur {set}, limité par le plus petit hôte",
"live-vcpu-scaling:description": "Les VM créées depuis un modèle dans ce pool peuvent recevoir des vCPU pendant qu'elles tournent, jusqu'à ce nombre. Les VM existantes et les VM Windows ne sont pas modifiées.",
"live-vcpu-scaling:guests": "Les invités Linux doivent activer les vCPU ajoutés : Ubuntu, Rocky et AlmaLinux le font, Debian demande une règle udev.",
"live-vcpu-scaling:invalid": "Le maximum doit être un nombre entier de 2 à 128",
"live-vcpu-scaling:memory": "Chaque vCPU possible réserve environ 1 Mio de mémoire de l'hôte par VM.",
"live-vcpu-scaling:up-to": "Jusqu'à {n} vCPU",
"load-at-startup": "Charger au démarrage",
"load-average": "Charge système",
"loaded": "Chargé",
@@ -964,6 +972,9 @@
"new-vm:user-config-variables-escape": "Échapper toute variable avec un antislash précédant '\\'",
"new-vm:user-config-variables-index": "{'{index}'} : l'index de la VM, il prendra 0 en cas de VM unique",
"new-vm:user-config-variables-name": "{'{name}'} : le nom de la VM. Il ne doit pas contenir '_'",
"new-vm:vcpus-at-max": "L'ajout de vCPU à chaud s'arrête à {n} sur ce pool : ajouter des vCPU plus tard demandera un arrêt",
"new-vm:vcpus-live-up-to": "Des vCPU peuvent être ajoutés pendant que la VM tourne, jusqu'à {n}",
"new-vm:vcpus-windows-halted": "Windows : les vCPU ne se changent que VM arrêtée",
"news": "Actualités",
"news-name": "Actualités {name}",
"next-run": "Prochain run",

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

@@ -0,0 +1,90 @@
<template>
<UiDrawer
class="pool-live-vcpu-scaling-drawer"
:on-dismiss="() => emit('cancel')"
@dismiss="emit('cancel')"
@confirm="onConfirm()"
>
<template #title>{{ t('live-vcpu-scaling') }}</template>
<template #content>
<div class="content">
<p class="typo-body-regular-small">{{ t('live-vcpu-scaling:description') }}</p>
<UiToggle v-model="enabled">{{ enabled ? t('enabled') : t('disabled') }}</UiToggle>
<VtsInputWrapper v-if="enabled" :label="t('maximum-vcpu')" :message="messages" wrap-message>
<UiInput v-model.number="max" type="number" accent="brand" />
</VtsInputWrapper>
</div>
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton :disabled="enabled && !isValid">
{{ t('action:save') }}
</VtsOverlayConfirmButton>
</template>
</UiDrawer>
</template>
<script setup lang="ts">
import {
DEFAULT_LIVE_VCPU_SCALING_MAX,
isValidLiveVcpuScalingMax,
} from '@/modules/pool/utils/xo-pool-live-vcpu-scaling.util.ts'
import type { InfoAccent } from '@core/components/ui/info/UiInfo.vue'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiDrawer from '@core/components/ui/drawer/UiDrawer.vue'
import UiInput from '@core/components/ui/input/UiInput.vue'
import UiToggle from '@core/components/ui/toggle/UiToggle.vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { current, cap } = defineProps<{
// current pool setting, undefined when off
current: number | undefined
// physical cores of the smallest host of the pool
cap: number | undefined
}>()
const emit = defineEmits<{
cancel: []
confirm: [value: number | null]
}>()
const { t } = useI18n()
const enabled = ref(current !== undefined)
const max = ref<number>(current ?? DEFAULT_LIVE_VCPU_SCALING_MAX)
const isValid = computed(() => isValidLiveVcpuScalingMax(max.value))
const messages = computed(() => {
const list: (string | { content: string; accent: InfoAccent })[] = []
if (!isValid.value) {
list.push({ content: t('live-vcpu-scaling:invalid'), accent: 'danger' })
} else if (cap !== undefined && max.value > cap) {
list.push({ content: t('live-vcpu-scaling:capped', { n: cap }), accent: 'warning' })
}
list.push(t('live-vcpu-scaling:guests'), t('live-vcpu-scaling:memory'))
return list
})
function onConfirm() {
emit('confirm', enabled.value ? max.value : null)
}
</script>
<style lang="postcss" scoped>
.pool-live-vcpu-scaling-drawer .content {
display: flex;
flex-direction: column;
gap: 2.4rem;
}
</style>

View File

@@ -19,26 +19,22 @@
<template v-if="server">
<VtsStateHero v-if="!arePoolsReady" format="panel" type="busy" size="medium" />
<template v-else>
<UiPanelCard v-if="server.error === undefined">
<UiPanelCardTitle :id="server.id" size="medium" :label="server.label" icon="object:pool" />
<UiAlert v-if="server.error !== undefined" accent="danger">
{{ t('connection-failed') }}
<template #description>
{{ t('unable-to-connect-to-the-pool') }}
</template>
</UiAlert>
<UiPanelCard v-if="poolInfo?.label">
<UiPanelCardTitle
:id="server.poolId"
size="medium"
:label="poolInfo.label"
:to="poolInfo.to"
:icon="poolInfo.icon"
:disabled="!isConnected"
/>
<div class="content">
<!-- Pool -->
<VtsCardRowKeyValue>
<template #key>{{ t('pool') }}</template>
<template #value>
<UiLink
v-if="server.poolId !== undefined && server.poolNameLabel !== undefined"
icon="object:pool"
size="small"
:to="{ name: '/pool/[id]/dashboard', params: { id: server.poolId } }"
>
{{ server.poolNameLabel }}
</UiLink>
</template>
<template v-if="server.poolId !== undefined" #addons>
<VtsCopyButton :value="server.poolId" />
</template>
</VtsCardRowKeyValue>
<!-- Description -->
<VtsCardRowKeyValue truncate align-top>
<template #key>{{ t('description') }}</template>
@@ -61,12 +57,6 @@
</VtsCardRowKeyValue>
</div>
</UiPanelCard>
<UiAlert v-else accent="danger">
{{ t('connection-failed') }}
<template #description>
{{ t('unable-to-connect-to-the-pool') }}
</template>
</UiAlert>
<UiPanelCard>
<UiPanelCardTitle size="medium" :label="t('connection')" />
<!-- status -->
@@ -153,6 +143,7 @@ import PoolConnectionToggleButton from '@/modules/pool/components/actions/connec
import PoolDownloadButton from '@/modules/pool/components/actions/download/PoolDownloadButton.vue'
import PoolForgetButton from '@/modules/pool/components/actions/forget/PoolForgetButton.vue'
import { useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { getPoolInfo } from '@/modules/pool/utils/xo-pool.util.ts'
import type { FrontXoServer } from '@/modules/server/remote-resources/use-xo-server-collection.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
@@ -164,7 +155,6 @@ import VtsTag from '@core/components/tag/VtsTag.vue'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiInfo from '@core/components/ui/info/UiInfo.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiLogEntryViewer from '@core/components/ui/log-entry-viewer/UiLogEntryViewer.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
@@ -190,8 +180,14 @@ const pool = useGetPoolById(() => server?.poolId)
const primaryHost = useGetHostById(() => server?.master)
const hosts = computed(() => (server?.poolId ? hostsByPool.value.get(server.poolId) : undefined))
const poolInfo = computed(() => (server ? getPoolInfo(server) : undefined))
const connectionState = computed(() => (server ? (server.error ? 'error' : server.status) : 'error'))
const isConnected = computed(() => connectionState.value === 'connected')
const connectionStatus = useMapper(
() => (server ? (server.error ? 'error' : server.status) : 'error'),
connectionState,
{
error: { accent: 'danger', text: t('unable-to-connect-to-the-pool') },
disconnected: { accent: 'muted', text: t('disconnected') },

View File

@@ -40,20 +40,47 @@
<VtsStatus :status="pool.migrationCompression ?? false" />
</template>
</VtsTabularKeyValueRow>
<VtsTabularKeyValueRow :label="t('live-vcpu-scaling')">
<template #value>
<div class="live-vcpu-scaling">
<VtsStatus :status="effectiveVcpusMax !== undefined" />
<span v-if="effectiveVcpusMax !== undefined">
{{ t('live-vcpu-scaling:up-to', { n: effectiveVcpusMax }) }}
</span>
<UiButtonIcon
v-tooltip="editLiveVcpuScalingErrorMessage ?? t('action:edit')"
icon="action:edit"
size="small"
accent="brand"
:disabled="!canEditLiveVcpuScaling"
@click="editLiveVcpuScaling()"
/>
</div>
<UiInfo v-if="isCapped" accent="info" class="capped" wrap>
{{ t('live-vcpu-scaling:capped-by-host', { set: pool.vcpusMaxHeadroom }) }}
</UiInfo>
</template>
</VtsTabularKeyValueRow>
</VtsTabularKeyValueList>
</UiCard>
</template>
<script setup lang="ts">
import { useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { usePoolLiveVcpuScalingEdit } from '@/modules/pool/composables/use-pool-live-vcpu-scaling-edit.composable.ts'
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { getEffectiveVcpusMax, getLiveVcpuScalingCap } from '@/modules/pool/utils/xo-pool-live-vcpu-scaling.util.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import VtsTabularKeyValueList from '@core/components/tabular-key-value-list/VtsTabularKeyValueList.vue'
import VtsTabularKeyValueRow from '@core/components/tabular-key-value-row/VtsTabularKeyValueRow.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiInfo from '@core/components/ui/info/UiInfo.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { pool } = defineProps<{
@@ -62,7 +89,38 @@ const { pool } = defineProps<{
const { t } = useI18n()
const { useGetHostById, areHostsReady } = useXoHostCollection()
const { useGetHostById, areHostsReady, hostsByPool } = useXoHostCollection()
const primaryHost = useGetHostById(() => pool.master)
const poolHosts = computed(() => hostsByPool.value.get(pool.id) ?? [])
const liveVcpuScalingCap = computed(() => getLiveVcpuScalingCap(poolHosts.value))
// what new VMs actually get: the setting, capped by the smallest host
const effectiveVcpusMax = computed(() => getEffectiveVcpusMax(pool.vcpusMaxHeadroom, poolHosts.value))
const isCapped = computed(
() =>
pool.vcpusMaxHeadroom !== undefined &&
effectiveVcpusMax.value !== undefined &&
effectiveVcpusMax.value < pool.vcpusMaxHeadroom
)
const { editLiveVcpuScaling, canEditLiveVcpuScaling, editLiveVcpuScalingErrorMessage } = usePoolLiveVcpuScalingEdit(
() => pool,
liveVcpuScalingCap
)
</script>
<style lang="postcss" scoped>
.live-vcpu-scaling {
display: flex;
align-items: center;
gap: 0.8rem;
}
.capped {
margin-block-start: 0.4rem;
}
</style>

View File

@@ -17,7 +17,7 @@ export interface PoolFilterableData extends FrontXoServer {
export interface PoolDisplayData extends PoolFilterableData {
// Formatted values for UI display
poolIcon: ReturnType<typeof icon>
poolIcon: ReturnType<typeof icon> | undefined
hostIcon: ReturnType<typeof objectIcon> | undefined
primaryHostIcon: ReturnType<typeof objectIcon> | undefined
primaryHostRightIcon: ReturnType<typeof icon> | undefined
@@ -66,7 +66,7 @@ export function usePoolEnhancedData(rawServers: MaybeRefOrGetter<FrontXoServer[]
return {
...pool,
poolIcon: icon('object:pool'),
poolIcon: getPoolInfo(pool).icon,
hostIcon,
primaryHostIcon: hostIcon,
primaryHostRightIcon,

View File

@@ -139,11 +139,19 @@ describe('getDisplayData', () => {
expect(displayData.primaryHostRightIcon).toBeUndefined()
})
it('always sets the pool icon', () => {
it('sets the pool icon when the pool has a name', () => {
getHostById.mockReturnValue(undefined)
const displayData = mountFirstDisplayData()
expect(displayData.poolIcon).toBe('object:pool')
})
it('omits the pool icon when the pool has no name', () => {
getHostById.mockReturnValue(undefined)
const displayData = mountFirstDisplayData([createServer({ poolNameLabel: undefined, poolId: undefined })])
expect(displayData.poolIcon).toBeUndefined()
})
})

View File

@@ -0,0 +1,41 @@
import { useXoPoolEditLiveVcpuScalingJob } from '@/modules/pool/jobs/xo-pool-edit-live-vcpu-scaling.job.ts'
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import { reactiveComputed } from '@vueuse/core'
import { type MaybeRefOrGetter, ref } from 'vue'
export function usePoolLiveVcpuScalingEdit(
rawPool: MaybeRefOrGetter<FrontXoPool>,
rawCap: MaybeRefOrGetter<number | undefined>
) {
const pool = toComputed(rawPool)
const cap = toComputed(rawCap)
const newValue = ref<number | null | undefined>(undefined)
const { run, canRun, isRunning, errorMessage } = useXoPoolEditLiveVcpuScalingJob(() => pool.value, newValue)
const { open } = useOverlay({
component: () => import('@/modules/pool/components/drawer/PoolLiveVcpuScalingDrawer.vue'),
events: {
onConfirm: (value: number | null) => {
newValue.value = value
run({ detached: true })
},
onCancel: true,
},
})
const props = reactiveComputed(() => ({
current: pool.value.vcpusMaxHeadroom,
cap: cap.value,
}))
return {
editLiveVcpuScaling: () => open({ props }),
canEditLiveVcpuScaling: canRun,
isEditingLiveVcpuScaling: isRunning,
editLiveVcpuScalingErrorMessage: errorMessage,
}
}

View File

@@ -0,0 +1,13 @@
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { defineJobArg } from '@core/packages/job'
export const xoPoolArg = defineJobArg({
identify: (pool: FrontXoPool) => pool.id,
toArray: false,
})
// vCPU maximum given to new VMs, null turns live vCPU scaling off
export const liveVcpuScalingMaxArg = defineJobArg<number | null>({
identify: false,
toArray: false,
})

View File

@@ -0,0 +1,39 @@
import { liveVcpuScalingMaxArg, xoPoolArg } from '@/modules/pool/jobs/xo-pool-args.ts'
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { isValidLiveVcpuScalingMax } from '@/modules/pool/utils/xo-pool-live-vcpu-scaling.util.ts'
import { fetchPatch } from '@/shared/utils/fetch.util.ts'
import { defineJob, JobError, JobRunningError } from '@core/packages/job'
import { useI18n } from 'vue-i18n'
export const useXoPoolEditLiveVcpuScalingJob = defineJob(
'pool.edit-live-vcpu-scaling',
[xoPoolArg, liveVcpuScalingMaxArg],
() => {
const { t } = useI18n()
return {
async run(pool: FrontXoPool, vcpusMaxHeadroom: number | null) {
await fetchPatch(`pools/${pool.id}`, { vcpusMaxHeadroom })
},
validate(isRunning, pool: FrontXoPool | undefined, vcpusMaxHeadroom: number | null | undefined) {
if (pool === undefined) {
throw new JobError(t('job:arg:pool-id-required'))
}
if (isRunning) {
throw new JobRunningError(t('job:edit:in-progress'))
}
// the value only exists once the drawer is confirmed
if (vcpusMaxHeadroom === undefined || vcpusMaxHeadroom === null) {
return
}
if (!isValidLiveVcpuScalingMax(vcpusMaxHeadroom)) {
throw new JobError(t('live-vcpu-scaling:invalid'))
}
},
}
}
)

View File

@@ -25,6 +25,7 @@ const poolFields = [
'name_description',
'haRebootVmOnInternalShutdown',
'current_operations',
'vcpusMaxHeadroom',
] as const satisfies readonly (keyof XoPool)[]
export const useXoPoolCollection = defineRemoteResource({

View File

@@ -0,0 +1,55 @@
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
// Mirrors packages/xo-server/src/xapi/_vcpuHeadroom.mjs: keep both in sync
export const DEFAULT_LIVE_VCPU_SCALING_MAX = 16
export const LIVE_VCPU_SCALING_MIN = 2
export const LIVE_VCPU_SCALING_MAX = 128
// Physical cores of a host: XAPI compares the vCPU maximum with the thread count, capping on cores keeps VMs
// startable if SMT is turned off later
export function getHostCores(cpuInfo: FrontXoHost['CPUs'] | undefined): number | undefined {
const threads = Number(cpuInfo?.cpu_count)
if (!(threads > 0)) {
return undefined
}
const threadsPerCore = Number(cpuInfo?.threads_per_core)
return threadsPerCore > 1 ? Math.floor(threads / threadsPerCore) : threads
}
// Physical cores of the smallest host: no new VM gets a higher vCPU maximum
export function getLiveVcpuScalingCap(hosts: Pick<FrontXoHost, 'CPUs'>[]): number | undefined {
const cores = hosts.map(host => getHostCores(host.CPUs)).filter((n): n is number => n !== undefined)
return cores.length === 0 ? undefined : Math.min(...cores)
}
// vCPU maximum a new VM gets in the pool, undefined when the pool did not opt in
export function getEffectiveVcpusMax(
setting: number | undefined,
hosts: Pick<FrontXoHost, 'CPUs'>[],
coresPerSocket = 1
): number | undefined {
if (setting === undefined) {
return undefined
}
const cap = getLiveVcpuScalingCap(hosts)
if (cap === undefined) {
return undefined
}
const target = Math.min(setting, cap)
return coresPerSocket > 1 ? target - (target % coresPerSocket) : target
}
export function isValidLiveVcpuScalingMax(value: unknown): value is number {
return (
typeof value === 'number' &&
Number.isInteger(value) &&
value >= LIVE_VCPU_SCALING_MIN &&
value <= LIVE_VCPU_SCALING_MAX
)
}

View File

@@ -0,0 +1,69 @@
import {
getEffectiveVcpusMax,
getHostCores,
getLiveVcpuScalingCap,
isValidLiveVcpuScalingMax,
} from '@/modules/pool/utils/xo-pool-live-vcpu-scaling.util.ts'
import { describe, expect, it } from 'vitest'
const host = (cpuCount: number, threadsPerCore = 1) => ({
CPUs: { cpu_count: String(cpuCount), threads_per_core: String(threadsPerCore) },
})
describe('getHostCores', () => {
it('counts physical cores', () => {
expect(getHostCores({ cpu_count: '16', threads_per_core: '2' })).toBe(8)
expect(getHostCores({ cpu_count: '4', threads_per_core: '1' })).toBe(4)
})
it('falls back to the thread count without threads_per_core', () => {
expect(getHostCores({ cpu_count: '12' })).toBe(12)
})
it('returns undefined without CPU information', () => {
expect(getHostCores(undefined)).toBeUndefined()
expect(getHostCores({})).toBeUndefined()
})
})
describe('getLiveVcpuScalingCap', () => {
it('is the smallest host', () => {
expect(getLiveVcpuScalingCap([host(64, 2), host(16, 2), host(48)])).toBe(8)
})
it('is undefined without hosts', () => {
expect(getLiveVcpuScalingCap([])).toBeUndefined()
})
})
describe('getEffectiveVcpusMax', () => {
it('is the setting when hosts are large enough', () => {
expect(getEffectiveVcpusMax(16, [host(64, 2)])).toBe(16)
})
it('is capped by the smallest host', () => {
expect(getEffectiveVcpusMax(16, [host(64, 2), host(8, 2)])).toBe(4)
})
it('keeps a multiple of the cores per socket', () => {
expect(getEffectiveVcpusMax(16, [host(64)], 3)).toBe(15)
expect(getEffectiveVcpusMax(16, [host(6)], 4)).toBe(4)
})
it('is undefined when the pool did not opt in', () => {
expect(getEffectiveVcpusMax(undefined, [host(64)])).toBeUndefined()
})
})
describe('isValidLiveVcpuScalingMax', () => {
it('accepts integers from 2 to 128', () => {
expect(isValidLiveVcpuScalingMax(2)).toBe(true)
expect(isValidLiveVcpuScalingMax(128)).toBe(true)
})
it('refuses anything else', () => {
for (const value of [1, 129, 2.5, Number.NaN, '16', undefined, null]) {
expect(isValidLiveVcpuScalingMax(value)).toBe(false)
}
})
})

View File

@@ -29,6 +29,8 @@ const vmTemplateFields = [
'bios_strings',
'secureBoot',
'high_availability',
'viridian',
'coresPerSocket',
] as const satisfies readonly (keyof XoVmTemplate)[]
export const useXoVmTemplateCollection = defineRemoteResource({

View File

@@ -168,7 +168,7 @@
<!-- RESOURCE MANAGEMENT SECTION -->
<UiTitle>{{ t('resource-management') }}</UiTitle>
<div class="resource-management-container">
<VtsInputWrapper :label="t('vcpus')">
<VtsInputWrapper :label="t('vcpus')" :message="liveVcpuScalingMessage" wrap-message>
<UiInput v-model.number="vmState.vCPU" type="number" accent="brand" />
</VtsInputWrapper>
<!-- TODO remove (GB) when we can use new selector -->
@@ -256,6 +256,7 @@ import { useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host
import { useXoNetworkCollection } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { useXoPifCollection } from '@/modules/pif/remote-resources/use-xo-pif-collection.ts'
import { useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { getEffectiveVcpusMax } from '@/modules/pool/utils/xo-pool-live-vcpu-scaling.util.ts'
import { useXoSrCollection } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import { useXoVbdCollection } from '@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'
import { type FrontXoVdi, useXoVdiCollection } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
@@ -434,6 +435,27 @@ const hosts = computed(() => {
return hostsByPool.value.get(vmState.pool.id) ?? []
})
// Only shown when the pool opted in to live vCPU scaling, mirrors what xo-server does at creation
const liveVcpuScalingMessage = computed(() => {
const template = vmState.new_vm_template
if (vmState.pool === undefined || template === undefined) {
return undefined
}
const effectiveMax = getEffectiveVcpusMax(vmState.pool.vcpusMaxHeadroom, hosts.value, template.coresPerSocket)
if (effectiveMax === undefined) {
return undefined
}
if (template.viridian) {
return t('new-vm:vcpus-windows-halted')
}
// the server never lowers the template maximum
const max = Math.max(effectiveMax, template.CPUs.max)
return vmState.vCPU >= max ? t('new-vm:vcpus-at-max', { n: max }) : t('new-vm:vcpus-live-up-to', { n: max })
})
const vmsTemplates = computed(() => {
if (!vmState.pool) {
return []

View File

@@ -11,10 +11,18 @@
> Users must be able to say: "Nice enhancement, I'm eager to test it"
- [Pool/Advanced] Live vCPU scaling (opt-in): VMs created from a template in the pool get a vCPU maximum (16 by default), so vCPUs can be added later while they run, without a shutdown. Capped at creation by the physical cores of the smallest host, so the VMs can start anywhere in the pool even with SMT turned off. Not applied to Windows VMs or to existing VMs. Linux guests must bring added vCPUs online: Ubuntu, Rocky and AlmaLinux do, Debian needs a udev rule. A replica or restore on a pool with smaller hosts keeps the maximum and may not start there (PR [#10729](https://github.com/vatesfr/xen-orchestra/pull/10729))
- [VM] Lowering the vCPUs of a running Windows VM is now refused: Windows kept running the removed vCPUs while XAPI reported the lower count (PR [#10729](https://github.com/vatesfr/xen-orchestra/pull/10729))
- [REST API] `PATCH /pools/{id}` with `vcpusMaxHeadroom` to set live vCPU scaling (PR [#10729](https://github.com/vatesfr/xen-orchestra/pull/10729))
- [XO6/Pool/System] Live vCPU scaling can be viewed and edited, showing the maximum new VMs actually get on this pool (PR [#10729](https://github.com/vatesfr/xen-orchestra/pull/10729))
- [XO6/New VM] The vCPUs field says up to how many vCPUs the VM will be able to add while it runs (PR [#10729](https://github.com/vatesfr/xen-orchestra/pull/10729))
### Bug fixes
> Users must be able to say: "I had this issue, happy to know it's fixed"
- [XO6/Pools] Pool side panel now shows the same pool name, UUID and icon as the pools table, and keeps the pool card when the pool is disconnected or unreachable (PR [#10714](https://github.com/vatesfr/xen-orchestra/pull/10714))
### Packages to release
> When modifying a package, add it here with its release type.
@@ -31,6 +39,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 minor
- @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

@@ -216,7 +216,7 @@ Exceptional individual contributors are awarded a free XOA Premium subscription.
## FreeBSD and OpenBSD
:::note Community territory
We do not test Xen Orchestra on FreeBSD or OpenBSD at all: everything below comes purely from community feedback, and may lag behind reality. If you run XO on these systems and want to help maintain these instructions, [contact us](../support-and-licencing/community.md): we would be happy to have more testers and maintainers for these targets.
We do not test Xen Orchestra on FreeBSD or OpenBSD at all: everything below comes purely from community feedback, and may lag behind reality. If you run XO on these systems and want to help maintain these instructions, [contact us](../support-and-licensing/community.md): we would be happy to have more testers and maintainers for these targets.
:::
### FreeBSD

View File

@@ -7,7 +7,7 @@ sidebar_position: 8
This page covers the problems you may encounter with your XOA, and how to get out of them.
:::warning
If you have issues with an XO installed [from the sources](install-from-sources.md), please go through the [community support checklist](../support-and-licencing/community.md) first.
If you have issues with an XO installed [from the sources](install-from-sources.md), please go through the [community support checklist](../support-and-licensing/community.md) first.
:::
## First reflex: `xoa check`

View File

@@ -164,7 +164,7 @@ the parked standby. */}
<LinkCard title="Set up backups" href="/backups-and-dr/backup">Rolling snapshots, incremental backups, replication and more.</LinkCard>
<LinkCard title="Migrate from VMware" href="/guides/v2v-migration-guide">Import your ESXi VMs directly into your pools with V2V.</LinkCard>
<LinkCard title="Automate everything" href="/automation/restapi">REST API, CLI, Terraform, Ansible, Pulumi and more.</LinkCard>
<LinkCard title="Get support" href="/support-and-licencing/support">Professional support by the Vates team, or community help.</LinkCard>
<LinkCard title="Get support" href="/support-and-licensing/support">Professional support by the Vates team, or community help.</LinkCard>
</CardGrid>
## What XO does for you

View File

@@ -1,5 +1,5 @@
{
"label": "Support and licencing",
"label": "Support and licensing",
"position": 8,
"link": {
"type": "generated-index",

View File

@@ -1,4 +1,4 @@
# Bundles and licences
# Bundles and licenses
The following resources are available in the Vates VMS documentation:

View File

@@ -91,8 +91,8 @@ export default {
from: '/xo6/backups',
},
{
to: '/support-and-licencing/community',
from: ['/xo6/community', '/community'],
to: '/support-and-licensing/community',
from: ['/xo6/community', '/community', '/support-and-licencing/community'],
},
{
to: '/getting-started/configuration',
@@ -135,8 +135,22 @@ export default {
from: '/xo5/backup',
},
{
to: '/support-and-licencing/support',
from: ['/license_management', '/xo5/license_management', '/xo6/support', '/support'],
to: '/support-and-licensing/support',
from: [
'/license_management',
'/xo5/license_management',
'/xo6/support',
'/support',
'/support-and-licencing/support',
],
},
{
to: '/support-and-licensing/useful-links',
from: '/support-and-licencing/useful-links',
},
{
to: '/category/support-and-licensing',
from: '/category/support-and-licencing',
},
{
to: '/manage-your-infrastructure/load_balancing',
@@ -171,7 +185,7 @@ export default {
from: ['/proxy', '/xo5/proxy'],
},
{
to: '/support-and-licencing/support#licensing',
to: '/support-and-licensing/support#licensing',
from: ['/purchase', '/xo6/purchase'],
},
{

View File

@@ -65,7 +65,7 @@ export function onRouteDidUpdate({ location }: { location: { pathname: string; h
if (path === '/getting-started/installation' && MOVED_FROM_XOA[anchor] !== undefined) {
window.location.replace(MOVED_FROM_XOA[anchor])
}
if (path === '/support-and-licencing/support' && MOVED_TO_VATES_DOCS[anchor] !== undefined) {
if (path === '/support-and-licensing/support' && MOVED_TO_VATES_DOCS[anchor] !== undefined) {
window.location.replace(MOVED_TO_VATES_DOCS[anchor])
}
}

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

@@ -9,6 +9,7 @@ import { incorrectState, invalidParameters } from 'xo-common/api-errors.js'
import { fromCallback } from 'promise-toolbox'
import { moveFirst } from '../_moveFirst.mjs'
import { HVM_MAX_VCPUS } from '../xapi/_vcpuHeadroom.mjs'
/** @typedef {import('@vates/types').XoNetwork} XoNetwork */
/** @typedef {import('@vates/types').XoPool} XoPool */
@@ -34,6 +35,7 @@ export async function set({
rpuMigrateVmsBack,
suspendSr,
crashDumpSr,
vcpusMaxHeadroom,
}) {
pool = this.getXapiObject(pool)
@@ -48,6 +50,7 @@ export async function set({
suspendSr !== undefined && pool.$call('set_suspend_image_SR', suspendSr === null ? Ref.EMPTY : suspendSr._xapiRef),
crashDumpSr !== undefined &&
pool.$call('set_crash_dump_SR', crashDumpSr === null ? Ref.EMPTY : crashDumpSr._xapiRef),
vcpusMaxHeadroom !== undefined && pool.$xapi.setVcpusMaxHeadroom(vcpusMaxHeadroom),
])
}
@@ -95,6 +98,14 @@ set.params = {
type: ['string', 'null'],
optional: true,
},
// vCPU maximum given to the VMs XO creates in this pool, so vCPUs can be added while they run (null: off)
vcpusMaxHeadroom: {
type: ['integer', 'null'],
minimum: 2,
maximum: HVM_MAX_VCPUS,
optional: true,
},
}
set.resolve = {

View File

@@ -11,6 +11,7 @@ import { extractIpFromVmNetworks } from './_extractIpFromVmNetworks.mjs'
import { extractProperty, forEach, isEmpty, mapFilter, parseXml } from './utils.mjs'
import { getVmDomainType, isHostRunning, isVmRunning } from './xapi/index.mjs'
import { useUpdateSystem } from './xapi/utils.mjs'
import { parseVcpusMaxSetting, POOL_KEY as VCPUS_MAX_POOL_KEY } from './xapi/_vcpuHeadroom.mjs'
const { debug, warn } = createLogger('xo:server:xapi-objects-to-xo')
@@ -220,6 +221,7 @@ const TRANSFORMS = {
sockets: cpuInfo && +cpuInfo.socket_count,
},
suspendSr: link(obj, 'suspend_image_SR'),
vcpusMaxHeadroom: parseVcpusMaxSetting(obj.other_config[VCPUS_MAX_POOL_KEY]),
zstdSupported: obj.restrictions.restrict_zstd_export === 'false',
vtpmSupported: obj.restrictions.restrict_vtpm === 'false',
platform_version: obj.$master.software_version.platform_version,

View File

@@ -0,0 +1,66 @@
// Live vCPU scaling: when a pool opts in, the VMs XO creates from a template get a vCPU maximum above the number
// they start with, so vCPUs can be added later while the VM runs. XAPI only lets VCPUs_max change on a halted VM.
// pool other_config key holding the vCPU maximum given to new VMs, absent when the pool did not opt in
export const POOL_KEY = 'xo:vcpusMaxHeadroom'
// value suggested when a pool opts in
export const DEFAULT_VCPUS_MAX = 16
// Xen limit for HVM guests (HVM_MAX_VCPUS), also the highest accepted setting
export const HVM_MAX_VCPUS = 128
export function parseVcpusMaxSetting(value) {
const n = Number(value)
return Number.isSafeInteger(n) && n >= 2 && n <= HVM_MAX_VCPUS ? n : undefined
}
// Physical cores of a host, from its XAPI cpu_info.
//
// XAPI refuses to start or migrate a VM whose VCPUs_max exceeds the host's cpu_count, which counts threads. Capping
// on cores rather than threads leaves a margin: VMs keep starting if SMT is turned off later, which halves
// cpu_count.
export function getHostCores(cpuInfo) {
const threads = +cpuInfo?.cpu_count
if (!(threads > 0)) {
return
}
const threadsPerCore = +cpuInfo.threads_per_core
return threadsPerCore > 1 ? Math.floor(threads / threadsPerCore) : threads
}
// Windows does not bring vCPUs online or offline the way Linux does: live removal is ignored by the guest, and
// the XCP-ng tools 9.2 stop answering clean shutdown requests after a live addition. Windows templates set the
// Xen PCI device used by Windows Update (device_id 0002) and the viridian enlightenments; a running VM with guest
// tools also reports its OS.
export const isWindowsVm = vm =>
vm.platform?.device_id === '0002' ||
vm.platform?.viridian === 'true' ||
vm.$guest_metrics?.os_version?.distro === 'windows'
// Whether the parameters of a new VM ask for a vCPU maximum of their own, which wins over the pool setting: only a
// maximum above what the VM would get anyway counts. XO 5 always sends max(vCPUs, template maximum).
export function hasExplicitVcpusMax(props, template) {
const requestedMax = +(props.cpusMax ?? props.cpusStaticMax)
const requestedCpus = +(props.cpus ?? props.CPUs) || 0
return requestedMax > Math.max(requestedCpus, +template.VCPUs_max || 0)
}
// Returns the vCPU maximum to set on a new VM, or undefined to leave it as is.
//
// - never above the physical cores of the smallest host of the pool (see getHostCores), so the VM can start, be
// restarted by HA and be evacuated anywhere in the pool
// - a multiple of the cores per socket, so the CPU topology stays valid
// - never lower than the current maximum
export function computeVcpusMax({ setting, hostCores, coresPerSocket, currentMax }) {
if (setting === undefined || hostCores.length === 0) {
return
}
let target = Math.min(setting, ...hostCores)
if (coresPerSocket > 1) {
target -= target % coresPerSocket
}
return target > currentMax ? target : undefined
}

View File

@@ -0,0 +1,111 @@
import assert from 'assert/strict'
import test from 'node:test'
import {
computeVcpusMax,
getHostCores,
hasExplicitVcpusMax,
isWindowsVm,
parseVcpusMaxSetting,
} from './_vcpuHeadroom.mjs'
const { describe, it } = test
const compute = opts => computeVcpusMax({ setting: 16, hostCores: [32], coresPerSocket: 1, currentMax: 2, ...opts })
describe('parseVcpusMaxSetting', () => {
it('accepts integers from 2 to 128', () => {
assert.equal(parseVcpusMaxSetting('16'), 16)
assert.equal(parseVcpusMaxSetting('2'), 2)
assert.equal(parseVcpusMaxSetting('128'), 128)
})
it('treats anything else as off', () => {
for (const value of [undefined, '', '0', '1', '129', '16.5', 'abc', 'true']) {
assert.equal(parseVcpusMaxSetting(value), undefined, String(value))
}
})
})
describe('computeVcpusMax', () => {
it('raises the maximum to the setting', () => {
assert.equal(compute(), 16)
})
it('does nothing when the pool did not opt in', () => {
assert.equal(compute({ setting: undefined }), undefined)
})
it('stays within the smallest host of the pool', () => {
assert.equal(compute({ hostCores: [64, 8, 32] }), 8)
})
it('does nothing without host information', () => {
assert.equal(compute({ hostCores: [] }), undefined)
})
it('keeps a multiple of the cores per socket', () => {
assert.equal(compute({ coresPerSocket: 2 }), 16)
assert.equal(compute({ coresPerSocket: 3 }), 15)
assert.equal(compute({ hostCores: [6], coresPerSocket: 4 }), 4)
})
it('never lowers the maximum', () => {
assert.equal(compute({ currentMax: 16 }), undefined)
assert.equal(compute({ currentMax: 24 }), undefined)
assert.equal(compute({ hostCores: [4], currentMax: 4 }), undefined)
})
})
describe('getHostCores', () => {
it('counts physical cores, so VMs still start if SMT is turned off', () => {
assert.equal(getHostCores({ cpu_count: '16', threads_per_core: '2' }), 8)
assert.equal(getHostCores({ cpu_count: '4', threads_per_core: '1' }), 4)
})
it('falls back to the thread count on XAPI versions without threads_per_core', () => {
assert.equal(getHostCores({ cpu_count: '12' }), 12)
})
it('returns nothing without CPU information', () => {
assert.equal(getHostCores(undefined), undefined)
assert.equal(getHostCores({}), undefined)
})
})
describe('hasExplicitVcpusMax', () => {
const template = { VCPUs_max: '2' }
it('counts a maximum above the vCPUs and the template maximum as a choice', () => {
assert.equal(hasExplicitVcpusMax({ cpus: 2, cpusMax: 8 }, template), true)
assert.equal(hasExplicitVcpusMax({ CPUs: 2, cpusStaticMax: 8 }, template), true)
})
it('ignores the maximum XO 5 always sends, max(vCPUs, template maximum)', () => {
assert.equal(hasExplicitVcpusMax({ CPUs: 4, cpusMax: 4 }, template), false)
assert.equal(hasExplicitVcpusMax({ CPUs: 1, cpusMax: 2 }, template), false)
})
it('treats a missing maximum as no choice', () => {
assert.equal(hasExplicitVcpusMax({ cpus: 2 }, template), false)
assert.equal(hasExplicitVcpusMax({}, template), false)
})
})
describe('isWindowsVm', () => {
it('recognizes Windows templates', () => {
assert.equal(isWindowsVm({ platform: { device_id: '0002', viridian: 'true' } }), true)
assert.equal(isWindowsVm({ platform: { viridian: 'true' } }), true)
assert.equal(isWindowsVm({ platform: { device_id: '0002' } }), true)
})
it('recognizes a running Windows VM from its guest tools', () => {
assert.equal(isWindowsVm({ platform: {}, $guest_metrics: { os_version: { distro: 'windows' } } }), true)
})
it('does not flag Linux templates', () => {
assert.equal(isWindowsVm({ platform: { viridian: 'false' } }), false)
assert.equal(isWindowsVm({ platform: {} }), false)
assert.equal(isWindowsVm({}), false)
})
})

View File

@@ -0,0 +1,182 @@
import assert from 'assert/strict'
import test from 'node:test'
import poolMethods from './pool.mjs'
import vmMethods from './vm.mjs'
const { describe, it } = test
const LINUX_PLATFORM = { 'cores-per-socket': '1', viridian: 'false' }
const WINDOWS_PLATFORM = { 'cores-per-socket': '2', device_id: '0002', viridian: 'true' }
// Fake XAPI modelling what the vCPU maximum and vCPU count depend on
function fakeXapi({ setting, hostThreads = [32], threadsPerCore = 1, vm: vmProps = {} } = {}) {
const calls = []
const fields = {}
const xapi = {
calls,
fields,
pool: { other_config: setting === undefined ? {} : { 'xo:vcpusMaxHeadroom': setting } },
objects: {
indexes: {
type: {
host: Object.fromEntries(
hostThreads.map((n, i) => [
i,
{ cpu_info: { cpu_count: String(n), threads_per_core: String(threadsPerCore) } },
])
),
},
},
},
vm: {
$ref: 'OpaqueRef:vm',
$type: 'VM',
domain_type: 'hvm',
platform: LINUX_PLATFORM,
power_state: 'Halted',
VCPUs_at_startup: '2',
VCPUs_max: '2',
...vmProps,
},
async call(method, ...args) {
calls.push([method, ...args])
},
async getField(type, ref, field) {
return xapi.vm[field]
},
getObject() {
return xapi.vm
},
async setField(type, ref, field, value) {
fields[field] = value
},
}
xapi.vm.$xapi = xapi
return xapi
}
const applyHeadroom = xapi => vmMethods._applyVcpusMaxHeadroom.call(xapi, xapi.vm.$ref)
describe('_applyVcpusMaxHeadroom', () => {
it('raises the vCPU maximum of a new VM to the pool setting', async () => {
const xapi = fakeXapi({ setting: '16' })
await applyHeadroom(xapi)
assert.deepEqual(xapi.calls, [['VM.set_VCPUs_max', 'OpaqueRef:vm', 16]])
})
it('does nothing when the pool did not opt in', async () => {
const xapi = fakeXapi()
await applyHeadroom(xapi)
assert.deepEqual(xapi.calls, [])
})
it('stays within the smallest host of the pool', async () => {
const xapi = fakeXapi({ setting: '16', hostThreads: [32, 4] })
await applyHeadroom(xapi)
assert.deepEqual(xapi.calls, [['VM.set_VCPUs_max', 'OpaqueRef:vm', 4]])
})
it('stays within the physical cores, so VMs still start with SMT off', async () => {
const xapi = fakeXapi({ setting: '16', hostThreads: [16], threadsPerCore: 2 })
await applyHeadroom(xapi)
assert.deepEqual(xapi.calls, [['VM.set_VCPUs_max', 'OpaqueRef:vm', 8]])
})
it('leaves Windows VMs alone', async () => {
const xapi = fakeXapi({ setting: '16', vm: { platform: WINDOWS_PLATFORM } })
await applyHeadroom(xapi)
assert.deepEqual(xapi.calls, [])
})
it('keeps a valid topology', async () => {
const xapi = fakeXapi({ setting: '16', hostThreads: [6], vm: { platform: { 'cores-per-socket': '4' } } })
await applyHeadroom(xapi)
assert.deepEqual(xapi.calls, [['VM.set_VCPUs_max', 'OpaqueRef:vm', 4]])
})
it('does not fail the VM creation when XAPI refuses', async () => {
const xapi = fakeXapi({ setting: '16' })
xapi.call = async () => {
throw new Error('XAPI refused')
}
await applyHeadroom(xapi)
})
})
describe('editing the vCPUs of a VM', () => {
const editCpus = (xapi, cpus) => vmMethods._editVm.call(xapi, 'vm', { cpus })
it('refuses to lower the vCPUs of a running Windows VM', async () => {
const xapi = fakeXapi({
vm: { platform: WINDOWS_PLATFORM, power_state: 'Running', VCPUs_at_startup: '4', VCPUs_max: '4' },
})
await assert.rejects(editCpus(xapi, 2), /Windows cannot remove vCPUs/)
assert.deepEqual(xapi.calls, [])
assert.deepEqual(xapi.fields, {})
})
it('refuses before changing anything else in the same edit', async () => {
const xapi = fakeXapi({
vm: { platform: WINDOWS_PLATFORM, power_state: 'Running', VCPUs_at_startup: '4', VCPUs_max: '4' },
})
await assert.rejects(vmMethods._editVm.call(xapi, 'vm', { cpus: 2, cpuWeight: 512 }), /Windows cannot remove/)
assert.deepEqual(xapi.calls, [])
assert.deepEqual(xapi.fields, {})
})
it('still adds vCPUs to a running Windows VM (behavior unchanged)', async () => {
const xapi = fakeXapi({
vm: { platform: WINDOWS_PLATFORM, power_state: 'Running', VCPUs_at_startup: '2', VCPUs_max: '4' },
})
await editCpus(xapi, 4)
assert.deepEqual(xapi.calls, [['VM.set_VCPUs_number_live', 'OpaqueRef:vm', '4']])
assert.deepEqual(xapi.fields, { VCPUs_at_startup: 4 })
})
it('lowers the vCPUs of a halted Windows VM', async () => {
const xapi = fakeXapi({ vm: { platform: WINDOWS_PLATFORM, VCPUs_at_startup: '4', VCPUs_max: '4' } })
await editCpus(xapi, 2)
assert.deepEqual(xapi.calls, [])
assert.deepEqual(xapi.fields, { VCPUs_at_startup: 2 })
})
it('lowers the vCPUs of a running Linux VM', async () => {
const xapi = fakeXapi({ vm: { power_state: 'Running', VCPUs_at_startup: '4', VCPUs_max: '4' } })
await editCpus(xapi, 2)
assert.deepEqual(xapi.calls, [['VM.set_VCPUs_number_live', 'OpaqueRef:vm', '2']])
assert.deepEqual(xapi.fields, { VCPUs_at_startup: 2 })
})
})
describe('setVcpusMaxHeadroom', () => {
const fakePoolXapi = () => {
const updates = []
return {
updates,
pool: {
async update_other_config(key, value) {
updates.push([key, value])
},
},
}
}
it('stores the setting on the pool, null turns it off', async () => {
const xapi = fakePoolXapi()
await poolMethods.setVcpusMaxHeadroom.call(xapi, 16)
await poolMethods.setVcpusMaxHeadroom.call(xapi, null)
assert.deepEqual(xapi.updates, [
['xo:vcpusMaxHeadroom', '16'],
['xo:vcpusMaxHeadroom', null],
])
})
it('refuses values outside 2 to 128', async () => {
const xapi = fakePoolXapi()
for (const value of [0, 1, 129, 2.5, NaN, '16']) {
await assert.rejects(poolMethods.setVcpusMaxHeadroom.call(xapi, value), /integer from 2 to 128/, String(value))
}
assert.deepEqual(xapi.updates, [])
})
})

View File

@@ -3,9 +3,10 @@ import { cancelable, timeout } from 'promise-toolbox'
import { createLogger } from '@xen-orchestra/log'
import { decorateObject } from '@vates/decorate-with'
import { defer as deferrable } from 'golike-defer'
import { incorrectState } from 'xo-common/api-errors.js'
import { incorrectState, invalidParameters } from 'xo-common/api-errors.js'
import { isHostRunning } from '../utils.mjs'
import { noopRpuRecorder } from '../../_rpuRecovery.mjs'
import { parseVcpusMaxSetting, POOL_KEY as VCPUS_MAX_POOL_KEY } from '../_vcpuHeadroom.mjs'
import { parseDateTime } from '@xen-orchestra/xapi'
import { Task } from '@xen-orchestra/mixins/Tasks.mjs'
import filter from 'lodash/filter.js'
@@ -29,6 +30,14 @@ const PINNED_VM_START_CONCURRENCY = 2
const setProgress = (task, progress) => task.set('progress', Math.round(progress))
const methods = {
// vCPU maximum given to the VMs XO creates in this pool (see _vcpuHeadroom.mjs), null turns it off
async setVcpusMaxHeadroom(value) {
if (value !== null && parseVcpusMaxSetting(value) !== value) {
throw invalidParameters(`the vCPU maximum must be an integer from 2 to 128, got ${value}`)
}
await this.pool.update_other_config(VCPUS_MAX_POOL_KEY, value === null ? null : String(value))
},
exportPoolMetadata($cancelToken) {
return this.getResource($cancelToken, PATH_DB_DUMP, {
task: this.task_create('Export pool metadata'),

View File

@@ -19,6 +19,14 @@ import { Ref } from 'xen-api'
import { parseSize } from '../../utils.mjs'
import { isVmHvm, isVmRunning, makeEditObject } from '../utils.mjs'
import {
computeVcpusMax,
getHostCores,
hasExplicitVcpusMax,
isWindowsVm,
parseVcpusMaxSetting,
POOL_KEY as VCPUS_MAX_POOL_KEY,
} from '../_vcpuHeadroom.mjs'
import { resetVmOtherConfig } from '@xen-orchestra/backups/_otherConfig.mjs'
const log = createLogger('xo:server:xapi:vm')
@@ -110,6 +118,10 @@ const methods = {
// Set VMs params.
await this._editVm(vm, props, checkLimits)
if (!hasExplicitVcpusMax(props, template)) {
await this._applyVcpusMaxHeadroom(vm.$ref)
}
// Sets boot parameters.
{
const isHvm = isVmHvm(vm)
@@ -322,6 +334,40 @@ const methods = {
return this.barrier(vm.$ref)
},
// Raises the vCPU maximum of a VM being created to the pool setting (see _vcpuHeadroom.mjs). Best effort: a
// failure leaves the VM as the template made it.
async _applyVcpusMaxHeadroom(vmRef) {
const setting = parseVcpusMaxSetting(this.pool.other_config[VCPUS_MAX_POOL_KEY])
if (setting === undefined) {
return
}
try {
// read from XAPI: the cached record may not have the values _editVm just set yet
const [currentMax, platform] = await Promise.all([
this.getField('VM', vmRef, 'VCPUs_max'),
this.getField('VM', vmRef, 'platform'),
])
if (isWindowsVm({ platform })) {
return
}
const target = computeVcpusMax({
setting,
hostCores: Object.values(this.objects.indexes.type.host ?? {})
.map(host => getHostCores(host.cpu_info))
.filter(n => n > 0),
coresPerSocket: +platform['cores-per-socket'] || 1,
currentMax: +currentMax,
})
if (target !== undefined) {
await this.call('VM.set_VCPUs_max', vmRef, target)
}
} catch (error) {
log.warn('could not raise the vCPU maximum of a new VM', { vmRef, error })
}
},
// High level method to edit a VM.
//
// Params do not correspond directly to XAPI props.
@@ -377,6 +423,13 @@ const methods = {
},
get: vm => +vm.VCPUs_at_startup,
// XAPI accepts it, but Windows keeps running the vCPUs: the VM would then use more vCPUs than XAPI reports
validate(value, vm) {
if (isVmRunning(vm) && value < +vm.VCPUs_at_startup && isWindowsVm(vm)) {
throw invalidParameters('Windows cannot remove vCPUs while it runs: shut the VM down to lower its vCPUs')
}
},
set: [
'VCPUs_at_startup',
(value, vm) => isVmRunning(vm) && vm.$xapi.call('VM.set_VCPUs_number_live', vm.$ref, String(value)),

View File

@@ -222,6 +222,12 @@ export const makeEditObject = specs => {
value = preprocess(value)
}
// runs before any setter, so a refused value leaves the object untouched
const { validate } = spec
if (validate) {
validate(value, object)
}
const { get } = spec
if (get) {
const current = get(object)

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',
@@ -995,6 +996,13 @@ const messages = {
crashDumpSr: 'Crash dump SR',
defaultMigrationNetwork: 'Default migration network',
migrationCompression: 'Migration compression',
liveVcpuScaling: 'Live vCPU scaling',
liveVcpuScalingMax: 'up to',
liveVcpuScalingInfo:
'VMs created from a template by XO get vCPUs that can be added while they run, up to this number. Not applied to Windows VMs or to existing VMs. Linux guests must bring added vCPUs online: Ubuntu, Rocky and AlmaLinux do, Debian needs a udev rule.',
liveVcpuScalingCapped: 'Limited to {n} on this pool: the physical cores of its smallest host.',
liveVcpuScalingMemory: 'Each possible vCPU reserves about 1 MiB of host memory per VM.',
liveVcpuScalingInvalid: 'The maximum must be a whole number from 2 to 128',
migrationCompressionDisabled: 'Migration compression is not available on this pool',
poolDisableHa: 'Disabling high availability',
poolDisableHaConfirm: 'Are you sure you want to disable high availability on this pool?',

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

View File

@@ -17,7 +17,7 @@ import { CustomFields } from 'custom-fields'
import { injectIntl } from 'react-intl'
import { forEach, isEmpty, map, values } from 'lodash'
import { SelectSr } from 'select-objects'
import { Text, XoSelect } from 'editable'
import { Number as EditableNumber, Text, XoSelect } from 'editable'
import { Toggle } from 'form'
import {
createGetObject,
@@ -55,6 +55,9 @@ import { Host, Pool } from '../../common/render-xo-item'
import { isAdmin } from '../../common/selectors'
import { ENTERPRISE, SOURCES, getXoaPlan } from '../../common/xoa-plans'
// matches DEFAULT_VCPUS_MAX in xo-server/src/xapi/_vcpuHeadroom.mjs
const DEFAULT_VCPUS_MAX_HEADROOM = 16
const BindLicensesButton = decorate([
addSubscriptions({
hvSupportedVersions: subscribeHvSupportedVersions,
@@ -361,6 +364,28 @@ export default class TabAdvanced extends Component {
_onChangeMigrationCompression = value => editPool(this.props.pool, { migrationCompression: value })
_onChangeLiveVcpuScaling = enabled =>
editPool(this.props.pool, { vcpusMaxHeadroom: enabled ? DEFAULT_VCPUS_MAX_HEADROOM : null })
_onChangeVcpusMaxHeadroom = async vcpusMaxHeadroom => {
// the editable number gives NaN for a non-number, which would be sent as null and turn the feature off
if (!Number.isInteger(vcpusMaxHeadroom)) {
throw new Error(this.props.intl.formatMessage(messages.liveVcpuScalingInvalid))
}
return editPool(this.props.pool, { vcpusMaxHeadroom })
}
// same cap as getHostCores in xo-server/src/xapi/_vcpuHeadroom.mjs: physical cores of the smallest host
_getSmallestHostCores = createSelector(
() => this.props.hosts,
hosts => {
const cores = map(hosts, ({ CPUs: { cpu_count: threads, threads_per_core: perCore } = {} }) =>
+perCore > 1 ? Math.floor(threads / perCore) : +threads
).filter(n => n > 0)
return cores.length === 0 ? undefined : Math.min(...cores)
}
)
_onChangeBackupNetwork = backupNetwork => editPool(this.props.pool, { backupNetwork: backupNetwork.id })
_removeBackupNetwork = () => editPool(this.props.pool, { backupNetwork: null })
@@ -478,6 +503,25 @@ export default class TabAdvanced extends Component {
</Tooltip>
</td>
</tr>
<tr>
<th>{_('liveVcpuScaling')}</th>
<td>
<Toggle value={pool.vcpusMaxHeadroom !== undefined} onChange={this._onChangeLiveVcpuScaling} />
{pool.vcpusMaxHeadroom !== undefined && (
<span className='ml-1'>
{_('liveVcpuScalingMax')}{' '}
<EditableNumber value={pool.vcpusMaxHeadroom} onChange={this._onChangeVcpusMaxHeadroom} />
</span>
)}
<br />
<small className='text-muted'>
{_('liveVcpuScalingInfo')}{' '}
{pool.vcpusMaxHeadroom > this._getSmallestHostCores() &&
_('liveVcpuScalingCapped', { n: this._getSmallestHostCores() })}{' '}
{_('liveVcpuScalingMemory')}
</small>
</td>
</tr>
<tr>
<th>{_('poolHeartbeatSr')}</th>
<td>