mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
11 Commits
c145a7a579
...
feat_vcpu_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c23816982d | ||
|
|
cadf27ce64 | ||
|
|
ec6f052a3e | ||
|
|
26a57e2c4e | ||
|
|
3a04ed65f9 | ||
|
|
9c6a59bb89 | ||
|
|
12aa9576b9 | ||
|
|
85541ce5d7 | ||
|
|
56a33c1639 | ||
|
|
7f6ea616ca | ||
|
|
5601a64703 |
@@ -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"
|
||||
|
||||
38
@vates/types/src/backup-expression-context.mts
Normal file
38
@vates/types/src/backup-expression-context.mts
Normal 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]
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ export default {
|
||||
'rolling-update': true,
|
||||
update: {
|
||||
tags: true,
|
||||
vcpusMaxHeadroom: true,
|
||||
},
|
||||
}
|
||||
|
||||
111
@xen-orchestra/backups/_buildContext.mjs
Normal file
111
@xen-orchestra/backups/_buildContext.mjs
Normal 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,
|
||||
}
|
||||
}
|
||||
147
@xen-orchestra/backups/_buildContext.test.mjs
Normal file
147
@xen-orchestra/backups/_buildContext.test.mjs
Normal 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()
|
||||
)
|
||||
})
|
||||
})
|
||||
24
@xen-orchestra/backups/_expressionPredicate.mjs
Normal file
24
@xen-orchestra/backups/_expressionPredicate.mjs
Normal 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))
|
||||
}
|
||||
95
@xen-orchestra/backups/_expressionPredicate.test.mjs
Normal file
95
@xen-orchestra/backups/_expressionPredicate.test.mjs
Normal 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 '))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'][] = []
|
||||
|
||||
@@ -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:<field> (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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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') },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
13
@xen-orchestra/web/src/modules/pool/jobs/xo-pool-args.ts
Normal file
13
@xen-orchestra/web/src/modules/pool/jobs/xo-pool-args.ts
Normal 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,
|
||||
})
|
||||
@@ -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'))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -25,6 +25,7 @@ const poolFields = [
|
||||
'name_description',
|
||||
'haRebootVmOnInternalShutdown',
|
||||
'current_operations',
|
||||
'vcpusMaxHeadroom',
|
||||
] as const satisfies readonly (keyof XoPool)[]
|
||||
|
||||
export const useXoPoolCollection = defineRemoteResource({
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,8 @@ const vmTemplateFields = [
|
||||
'bios_strings',
|
||||
'secureBoot',
|
||||
'high_availability',
|
||||
'viridian',
|
||||
'coresPerSocket',
|
||||
] as const satisfies readonly (keyof XoVmTemplate)[]
|
||||
|
||||
export const useXoVmTemplateCollection = defineRemoteResource({
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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-->
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"label": "Support and licencing",
|
||||
"label": "Support and licensing",
|
||||
"position": 8,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
@@ -1,4 +1,4 @@
|
||||
# Bundles and licences
|
||||
# Bundles and licenses
|
||||
|
||||
The following resources are available in the Vates VMS documentation:
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'll only use this to ask about your feedback or let you know when it'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'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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
26
packages/xo-server/src/api/_backupExpressions.mjs
Normal file
26
packages/xo-server/src/api/_backupExpressions.mjs
Normal 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' },
|
||||
])
|
||||
}
|
||||
}
|
||||
77
packages/xo-server/src/api/_backupExpressions.test.mjs
Normal file
77
packages/xo-server/src/api/_backupExpressions.test.mjs
Normal 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))
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
66
packages/xo-server/src/xapi/_vcpuHeadroom.mjs
Normal file
66
packages/xo-server/src/xapi/_vcpuHeadroom.mjs
Normal 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
|
||||
}
|
||||
111
packages/xo-server/src/xapi/_vcpuHeadroom.test.mjs
Normal file
111
packages/xo-server/src/xapi/_vcpuHeadroom.test.mjs
Normal 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)
|
||||
})
|
||||
})
|
||||
182
packages/xo-server/src/xapi/mixins/_vm.test.mjs
Normal file
182
packages/xo-server/src/xapi/mixins/_vm.test.mjs
Normal 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, [])
|
||||
})
|
||||
})
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user