mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
7 Commits
8025af3cc8
...
stale-obje
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e634802822 | ||
|
|
bec7d28403 | ||
|
|
6885cb870d | ||
|
|
4155fcbe17 | ||
|
|
f470b1f5c7 | ||
|
|
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]
|
||||
@@ -52,6 +52,12 @@ type BaseXapiXo = {
|
||||
*/
|
||||
$poolId: XoPool['id']
|
||||
|
||||
/**
|
||||
* Only present while the pool is disconnected: the object is kept but may be
|
||||
* outdated, this is the last time (in milliseconds) the pool was reached
|
||||
*/
|
||||
$lastSeen?: number
|
||||
|
||||
_xapiRef: string
|
||||
|
||||
uuid: string
|
||||
@@ -552,8 +558,8 @@ export type XoVmBackupJobGeneralSettings = XoBackupJobGeneralSettings & {
|
||||
}
|
||||
export type XoVmBackupJobScheduleSettings = {
|
||||
exportRetention?: number
|
||||
healthCheckVmsWithTags?: XoVm['tags']
|
||||
fullInterval?: number
|
||||
healthCheckVmsWithTags?: XoVm['tags'] | string
|
||||
fullInterval?: number | string
|
||||
copyRetention?: number
|
||||
snapshotRetention?: number
|
||||
cbtDestroySnapshotData?: boolean
|
||||
@@ -564,9 +570,9 @@ export type XoVmBackupJob = BaseXoJob & {
|
||||
compression?: 'native' | 'zstd' | ''
|
||||
proxy?: XoProxy['id']
|
||||
mode: 'full' | 'delta'
|
||||
remotes?: CMType.IdOr<XoBackupRepository['id']>
|
||||
remotes?: CMType.IdOr<XoBackupRepository['id']> | string
|
||||
vms: CMType.IdOr<XoVm['id']> | Record<string, unknown>
|
||||
srs?: CMType.IdOr<XoSr['id']>
|
||||
srs?: CMType.IdOr<XoSr['id']> | string
|
||||
type: 'backup'
|
||||
settings: {
|
||||
'': XoVmBackupJobGeneralSettings
|
||||
@@ -598,13 +604,15 @@ export type XoMirrorBackupGeneralSettings = XoBackupJobGeneralSettings & {
|
||||
nRetriesVmBackupFailures?: number
|
||||
mergeBackupsSynchronously?: boolean
|
||||
timeout?: number
|
||||
timezone?: string
|
||||
maxExportRate?: number
|
||||
backupReportTpl?: 'compactMjml'
|
||||
reportWhen: 'failure'
|
||||
}
|
||||
export type XoMirrorBackupScheduleSettings = {
|
||||
exportRetention?: number
|
||||
healthCheckVmsWithTags?: XoVm['tags']
|
||||
healthCheckVmsWithTags?: XoVm['tags'] | string
|
||||
vmFilter?: string
|
||||
healthCheckSr?: XoSr['id']
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
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'][] = []
|
||||
|
||||
@@ -75,9 +75,14 @@ export class HostService {
|
||||
let nHostsFailed = 0
|
||||
|
||||
await asyncEach(hosts, async (host: XoHost) => {
|
||||
const xapi = this.#restApi.xoApp.getXapi(host)
|
||||
// its pool is disconnected: what it misses is unknown until it is back
|
||||
if (host.$lastSeen !== undefined) {
|
||||
nHostsFailed++
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const xapi = this.#restApi.xoApp.getXapi(host)
|
||||
const patches = await xapi.listMissingPatches(host.id)
|
||||
|
||||
if (patches.length > 0) {
|
||||
|
||||
58
@xen-orchestra/rest-api/src/hosts/host.service.test.mts
Normal file
58
@xen-orchestra/rest-api/src/hosts/host.service.test.mts
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import { HostService } from './host.service.mjs'
|
||||
import type { RestApi } from '../rest-api/rest-api.mjs'
|
||||
|
||||
describe('HostService.getMissingPatchesInfo', () => {
|
||||
it('counts the hosts of a disconnected pool as failed, and still reports the others', async () => {
|
||||
const hosts = {
|
||||
live: { id: 'live', $pool: 'pool-live' },
|
||||
stale: { id: 'stale', $pool: 'pool-stale', $lastSeen: Date.now() },
|
||||
}
|
||||
const queried: string[] = []
|
||||
const xapi = {
|
||||
listMissingPatches: async (hostId: string) => {
|
||||
queried.push(hostId)
|
||||
return [{ name: 'patch-1' }]
|
||||
},
|
||||
}
|
||||
const restApi = {
|
||||
getObjectsByType: () => hosts,
|
||||
xoApp: {
|
||||
checkFeatureAuthorization: async () => {},
|
||||
getXapi: (host: { $lastSeen?: number }) => {
|
||||
if (host.$lastSeen !== undefined) {
|
||||
throw new Error('the pool of this object is disconnected')
|
||||
}
|
||||
return xapi
|
||||
},
|
||||
},
|
||||
} as unknown as RestApi
|
||||
|
||||
const info = await new HostService(restApi).getMissingPatchesInfo()
|
||||
|
||||
assert.deepEqual(queried, ['live'])
|
||||
assert.equal(info.nHostsFailed, 1)
|
||||
assert.equal(info.nHostsWithMissingPatches, 1)
|
||||
assert.equal(info.nPoolsWithMissingPatches, 1)
|
||||
assert.deepEqual(info.missingPatches, [{ name: 'patch-1' }])
|
||||
})
|
||||
|
||||
it('counts a host whose XAPI cannot be found as failed, instead of failing every pool', async () => {
|
||||
const restApi = {
|
||||
getObjectsByType: () => ({ gone: { id: 'gone', $pool: 'pool-gone' } }),
|
||||
xoApp: {
|
||||
checkFeatureAuthorization: async () => {},
|
||||
getXapi: () => {
|
||||
throw new Error('no connection found for this object')
|
||||
},
|
||||
},
|
||||
} as unknown as RestApi
|
||||
|
||||
const info = await new HostService(restApi).getMissingPatchesInfo()
|
||||
|
||||
assert.equal(info.nHostsFailed, 1)
|
||||
assert.deepEqual(info.missingPatches, [])
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
|
||||
> Users must be able to say: "Nice enhancement, I'm eager to test it"
|
||||
|
||||
- [Servers] The objects of a pool which can no longer be reached are kept and flagged as disconnected, with the last time the pool was reached, instead of disappearing. They are flagged after 30 seconds instead of 5 minutes (`xapiMarkDisconnectedDelay`), and removed after 1 hour (`xapiStaleObjectsMaxAge`) (PR [#10611](https://github.com/vatesfr/xen-orchestra/pull/10611))
|
||||
- [Servers] The time allowed to fetch the objects of a pool on connection is now set by `xapiObjectsFetchTimeout` (5 minutes), no longer by `xapiMarkDisconnectedDelay` (PR [#10611](https://github.com/vatesfr/xen-orchestra/pull/10611))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
> Users must be able to say: "I had this issue, happy to know it's fixed"
|
||||
@@ -31,6 +34,17 @@
|
||||
|
||||
<!--packages-start-->
|
||||
|
||||
- @vates/types minor
|
||||
- @xen-orchestra/backups minor
|
||||
- @xen-orchestra/qa-test patch
|
||||
- @xen-orchestra/rest-api patch
|
||||
- @xen-orchestra/web patch
|
||||
- @xen-orchestra/web-core minor
|
||||
- xo-server minor
|
||||
- xo-server-load-balancer patch
|
||||
- xo-server-netbox patch
|
||||
- xo-server-perf-alert patch
|
||||
- xo-server-usage-report patch
|
||||
- xo-web minor
|
||||
|
||||
<!--packages-end-->
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -258,6 +258,8 @@ export default class Plan {
|
||||
this.xo.getObjects(),
|
||||
object =>
|
||||
object.type === 'host' &&
|
||||
// stale objects of a disconnected pool cannot be acted upon
|
||||
object.$lastSeen === undefined &&
|
||||
includes(this._poolIds, object.$poolId) &&
|
||||
object.power_state === powerState &&
|
||||
!includes(this._excludedHosts, object.id) &&
|
||||
@@ -270,6 +272,7 @@ export default class Plan {
|
||||
this.xo.getObjects(),
|
||||
object =>
|
||||
object.type === 'VM' &&
|
||||
object.$lastSeen === undefined &&
|
||||
object.power_state === 'Running' &&
|
||||
!object.tags.some(tag => this._globalOptions.ignoredVmTags.has(tag))
|
||||
)
|
||||
|
||||
@@ -437,7 +437,9 @@ class Netbox {
|
||||
const clustersToUpdate = []
|
||||
for (const xoPoolId of xoPools) {
|
||||
const xoPool = this.getObject(xoPoolId)
|
||||
if (xoPool === undefined) {
|
||||
// the objects of a disconnected pool are stale: synchronizing them would
|
||||
// spread outdated data, skip the pool as if it was not found
|
||||
if (xoPool === undefined || xoPool.$lastSeen !== undefined) {
|
||||
// If we can't find the pool, don't synchronize anything within that pool
|
||||
log.warn('Synchronizing pools: cannot find pool', { pool: xoPoolId })
|
||||
delete allNbClusters[xoPoolId]
|
||||
|
||||
@@ -303,7 +303,8 @@ export class RrdHostVm extends MonitorStrategy {
|
||||
* @type {Array<XoHost>}
|
||||
*/
|
||||
|
||||
const hosts = Object.values(this.#xo.objects.indexes.type.host ?? {})
|
||||
// the RRDs of the stale hosts of a disconnected pool are not reachable
|
||||
const hosts = Object.values(this.#xo.objects.indexes.type.host ?? {}).filter(host => host.$lastSeen === undefined)
|
||||
/**
|
||||
* @type {Map<XoHost['id'], Set<XoVm['id']>}
|
||||
*/
|
||||
|
||||
@@ -197,7 +197,8 @@ export class XapiPerfmon extends MonitorStrategy {
|
||||
*/
|
||||
async updateObjectsPerfmon(xoObjects) {
|
||||
return asyncEach(Object.values(xoObjects), xoObject => {
|
||||
if (!['host', 'VM'].includes(xoObject.type)) {
|
||||
// stale objects of a disconnected pool cannot be configured
|
||||
if (!['host', 'VM'].includes(xoObject.type) || xoObject.$lastSeen !== undefined) {
|
||||
return
|
||||
}
|
||||
process.stdout.write('.')
|
||||
|
||||
@@ -659,7 +659,8 @@ async function computeEvolution({ storedStatsPath, ...newStats }) {
|
||||
}
|
||||
|
||||
async function dataBuilder({ currDate, periodicity, xo, storedStatsPath, all }) {
|
||||
const xoObjects = values(xo.getObjects())
|
||||
// objects of disconnected pools are kept by XO but their stats are not reachable
|
||||
const xoObjects = values(xo.getObjects()).filter(object => object.$lastSeen === undefined)
|
||||
const runningVms = filter(xoObjects, { type: 'VM', power_state: 'Running' })
|
||||
const haltedVms = filter(xoObjects, { type: 'VM', power_state: 'Halted' })
|
||||
const runningHosts = filter(xoObjects, {
|
||||
|
||||
@@ -34,8 +34,17 @@ guessVhdSizeOnImport = true
|
||||
# be turned for investigation by the administrator.
|
||||
verboseApiLogsOnErrors = false
|
||||
|
||||
# if no events could be fetched during this delay, the server will be marked as disconnected
|
||||
xapiMarkDisconnectedDelay = '5 minutes'
|
||||
# if no events could be fetched during this delay, the objects of the pool are
|
||||
# flagged as stale and the server is shown as connecting until events are back
|
||||
xapiMarkDisconnectedDelay = '30 seconds'
|
||||
|
||||
# if the objects of a pool could not be fetched during this delay, the
|
||||
# connection to it fails
|
||||
xapiObjectsFetchTimeout = '5 minutes'
|
||||
|
||||
# once a pool is marked as disconnected, its objects are kept, flagged with
|
||||
# `$lastSeen`, until it is reconnected or at most during this delay
|
||||
xapiStaleObjectsMaxAge = '1 hour'
|
||||
|
||||
# Delay before re-enabling the load balancer after a rolling pool update
|
||||
loadBalancerReEnableDelay = '30 minutes'
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import EventEmitter from 'node:events'
|
||||
import { afterEach, describe, it, mock } from 'node:test'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { configure } from '@xen-orchestra/log/configure'
|
||||
|
||||
configure({ level: 'FATAL', transport: () => {} })
|
||||
|
||||
// default value of the duration settings (`xapiMarkDisconnectedDelay`,
|
||||
// `xapiStaleObjectsMaxAge` and `xapiObjectsFetchTimeout`)
|
||||
const DELAY = 100
|
||||
const LAST_SEEN = 1e12
|
||||
|
||||
const xapis = []
|
||||
// XAPI objects provided by the next connection
|
||||
let nextXapiObjects
|
||||
// time taken by the next connection to fetch its objects
|
||||
let nextObjectsFetchDuration
|
||||
|
||||
const xapiObject = ($type, $id) => ({ $id, $ref: `OpaqueRef:${$id}`, $type })
|
||||
|
||||
class FakeXapi extends EventEmitter {
|
||||
lastEventFetchedTimestamp = LAST_SEEN
|
||||
objectsFetched = sleep(nextObjectsFetchDuration)
|
||||
pool = { $id: 'pool-1', $ref: 'OpaqueRef:pool-1', master: 'OpaqueRef:master', other_config: {}, uuid: 'pool-1' }
|
||||
// avoids the pool marking
|
||||
readOnly = true
|
||||
status = 'connected'
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.objects = Object.assign(new EventEmitter(), {
|
||||
all: Object.fromEntries(nextXapiObjects.map(object => [object.$id, object])),
|
||||
})
|
||||
xapis.push(this)
|
||||
}
|
||||
|
||||
async connect() {}
|
||||
|
||||
async disconnect() {
|
||||
this.status = 'disconnected'
|
||||
this.emit('disconnected')
|
||||
}
|
||||
|
||||
getObjectByRef() {
|
||||
return { uuid: 'host-1' }
|
||||
}
|
||||
|
||||
_interruptOnDisconnect(promise) {
|
||||
return promise
|
||||
}
|
||||
|
||||
watchEvents() {}
|
||||
|
||||
// `lastEventFetchedTimestamp` is old, as after an event fetching error
|
||||
watchEventsError = new Error('ETIMEDOUT')
|
||||
}
|
||||
|
||||
const xapiModuleUrl = new URL('../xapi/index.mjs', import.meta.url).href
|
||||
const { default: _, ...xapiNamedExports } = await import(xapiModuleUrl)
|
||||
mock.module(xapiModuleUrl, { defaultExport: FakeXapi, namedExports: xapiNamedExports })
|
||||
mock.module(new URL('../xapi-object-to-xo.mjs', import.meta.url).href, {
|
||||
defaultExport: ({ $id, $ref, $type }) => ({ id: $id, type: $type, $pool: 'pool-1', _xapiRef: $ref }),
|
||||
})
|
||||
|
||||
const { default: XenServers } = await import('./xen-servers.mjs')
|
||||
|
||||
const createXenServers = ({ durations = {} } = {}) => {
|
||||
xapis.length = 0
|
||||
nextObjectsFetchDuration = 0
|
||||
nextXapiObjects = [xapiObject('pool', 'pool-1'), xapiObject('VM', 'vm-1'), xapiObject('VM', 'vm-2')]
|
||||
|
||||
const xoObjects = new Map()
|
||||
const resolved = async () => {}
|
||||
const app = {
|
||||
addConfigManager() {},
|
||||
config: {
|
||||
get: key =>
|
||||
key === 'xapiOptions'
|
||||
? { poolMarkingInterval: '1 hour', poolMarkingMaxAge: '1 hour', poolMarkingPrefix: 'xo:' }
|
||||
: undefined,
|
||||
getOptional() {},
|
||||
watchDuration: (key, cb) => cb(durations[key] ?? DELAY),
|
||||
},
|
||||
emit() {},
|
||||
getObject: id => xoObjects.get(id),
|
||||
hooks: new EventEmitter(),
|
||||
httpRequest() {},
|
||||
_objects: {
|
||||
get: (id, defaultValue) => (xoObjects.has(id) ? xoObjects.get(id) : defaultValue),
|
||||
set: (id, value) => xoObjects.set(id, value),
|
||||
unset: id => {
|
||||
assert(xoObjects.delete(id), `no such object ${id}`)
|
||||
},
|
||||
},
|
||||
_redis: {
|
||||
del: resolved,
|
||||
get: async () => null,
|
||||
keys: async () => [],
|
||||
sAdd: resolved,
|
||||
set: resolved,
|
||||
sMembers: resolved,
|
||||
},
|
||||
}
|
||||
const xenServers = new XenServers(app, { safeMode: true })
|
||||
app.hooks.emit('core started')
|
||||
|
||||
const stored = {
|
||||
'server-1': {
|
||||
id: 'server-1',
|
||||
enabled: true,
|
||||
host: '192.0.2.1',
|
||||
password: 'secret',
|
||||
status: 'disconnected',
|
||||
username: 'root',
|
||||
},
|
||||
}
|
||||
xenServers._servers = {
|
||||
first: async id => (stored[id] === undefined ? undefined : { ...stored[id] }),
|
||||
update: async model => {
|
||||
Object.assign(stored[model.id], model)
|
||||
},
|
||||
}
|
||||
xenServers._autoReconnectXenServer = () => {}
|
||||
|
||||
const lastSeenById = () => Object.fromEntries([...xoObjects].map(([id, object]) => [id, object.$lastSeen]))
|
||||
|
||||
const serverStatus = () => stored['server-1'].status
|
||||
|
||||
return { lastSeenById, serverStatus, xenServers }
|
||||
}
|
||||
|
||||
// stops the pool marking interval of the connections left open
|
||||
afterEach(async function () {
|
||||
for (const xapi of xapis) {
|
||||
if (xapi.status !== 'disconnected') {
|
||||
await xapi.disconnect()
|
||||
}
|
||||
}
|
||||
await sleep(DELAY * 2)
|
||||
})
|
||||
|
||||
describe('objects of a lost connection', function () {
|
||||
it('are flagged after the grace period, and unflagged once events are back', async function () {
|
||||
const { lastSeenById, xenServers } = createXenServers()
|
||||
await xenServers.connectXenServer('server-1')
|
||||
|
||||
xapis[0].emit('eventFetchingError', new Error('ETIMEDOUT'))
|
||||
await sleep(DELAY * 1.5)
|
||||
assert.deepEqual(lastSeenById(), { 'pool-1': LAST_SEEN, 'vm-1': LAST_SEEN, 'vm-2': LAST_SEEN })
|
||||
|
||||
xapis[0].emit('eventFetchingSuccess')
|
||||
assert.deepEqual(lastSeenById(), { 'pool-1': undefined, 'vm-1': undefined, 'vm-2': undefined })
|
||||
})
|
||||
|
||||
it('are unflagged, and the server connected, when refetched before events are back', async function () {
|
||||
const { lastSeenById, serverStatus, xenServers } = createXenServers()
|
||||
await xenServers.connectXenServer('server-1')
|
||||
|
||||
xapis[0].emit('eventFetchingError', new Error('ECONNRESET'))
|
||||
await sleep(DELAY * 1.5)
|
||||
assert.equal(serverStatus(), 'connecting')
|
||||
|
||||
// a failover refetches all the objects before fetching events again
|
||||
xapis[0].objects.emit('update', xapis[0].objects.all)
|
||||
xapis[0].emit('eventFetchingSuccess')
|
||||
await sleep(DELAY * 1.5)
|
||||
assert.equal(serverStatus(), 'connected')
|
||||
assert.deepEqual(lastSeenById(), { 'pool-1': undefined, 'vm-1': undefined, 'vm-2': undefined })
|
||||
})
|
||||
|
||||
it('are removed when the pool stays unreachable longer than the max age', async function () {
|
||||
const { lastSeenById, xenServers } = createXenServers()
|
||||
await xenServers.connectXenServer('server-1')
|
||||
|
||||
xapis[0].emit('eventFetchingError', new Error('ETIMEDOUT'))
|
||||
await sleep(DELAY * 2.5)
|
||||
assert.deepEqual(lastSeenById(), {})
|
||||
|
||||
// the connection recovers from scratch
|
||||
xapis[0].emit('eventFetchingSuccess')
|
||||
assert.deepEqual(lastSeenById(), { 'pool-1': undefined, 'vm-1': undefined, 'vm-2': undefined })
|
||||
})
|
||||
|
||||
it('are kept on an unexpected disconnection, then reconciled by the next connection', async function () {
|
||||
const { lastSeenById, xenServers } = createXenServers()
|
||||
await xenServers.connectXenServer('server-1')
|
||||
|
||||
// e.g. `SESSION_INVALID` after a toolstack restart
|
||||
await xapis[0].disconnect()
|
||||
await sleep(10)
|
||||
assert.deepEqual(lastSeenById(), { 'pool-1': LAST_SEEN, 'vm-1': LAST_SEEN, 'vm-2': LAST_SEEN })
|
||||
|
||||
await assert.rejects(
|
||||
async () => xenServers.getXenServerIdByObject('vm-1'),
|
||||
/the pool of object vm-1 is disconnected, last seen at /
|
||||
)
|
||||
|
||||
// `vm-2` has been destroyed in the meantime
|
||||
nextXapiObjects = [xapiObject('pool', 'pool-1'), xapiObject('VM', 'vm-1')]
|
||||
await xenServers.connectXenServer('server-1', { enable: false })
|
||||
assert.deepEqual(lastSeenById(), { 'pool-1': undefined, 'vm-1': undefined })
|
||||
assert.equal(xenServers.getXenServerIdByObject('vm-1'), 'server-1')
|
||||
})
|
||||
|
||||
it('are kept, flagged, when an attempt to reconnect fails after fetching some of them', async function () {
|
||||
const { lastSeenById, xenServers } = createXenServers({
|
||||
durations: { xapiObjectsFetchTimeout: DELAY, xapiStaleObjectsMaxAge: DELAY * 20 },
|
||||
})
|
||||
await xenServers.connectXenServer('server-1')
|
||||
await xapis[0].disconnect()
|
||||
await sleep(10)
|
||||
|
||||
// the next attempt provides part of the objects, then times out
|
||||
nextXapiObjects = [xapiObject('pool', 'pool-1'), xapiObject('VM', 'vm-1')]
|
||||
nextObjectsFetchDuration = DELAY * 2
|
||||
await assert.rejects(xenServers.connectXenServer('server-1'), {
|
||||
data: { objectId: 'server-1', code: 'TIMEOUT_CONNECT_SERVER' },
|
||||
})
|
||||
assert.deepEqual(lastSeenById(), { 'pool-1': LAST_SEEN, 'vm-1': LAST_SEEN, 'vm-2': LAST_SEEN })
|
||||
|
||||
// and the next connection reconciles all of them
|
||||
nextXapiObjects = [xapiObject('pool', 'pool-1'), xapiObject('VM', 'vm-1'), xapiObject('VM', 'vm-2')]
|
||||
nextObjectsFetchDuration = 0
|
||||
await xenServers.connectXenServer('server-1')
|
||||
assert.deepEqual(lastSeenById(), { 'pool-1': undefined, 'vm-1': undefined, 'vm-2': undefined })
|
||||
})
|
||||
|
||||
it('are removed when a first connection fails after fetching some of them', async function () {
|
||||
const { lastSeenById, xenServers } = createXenServers({ durations: { xapiObjectsFetchTimeout: DELAY } })
|
||||
nextObjectsFetchDuration = DELAY * 2
|
||||
|
||||
await assert.rejects(xenServers.connectXenServer('server-1'), {
|
||||
data: { objectId: 'server-1', code: 'TIMEOUT_CONNECT_SERVER' },
|
||||
})
|
||||
assert.deepEqual(lastSeenById(), {})
|
||||
})
|
||||
|
||||
it('are removed after the max age when the server does not reconnect', async function () {
|
||||
const { lastSeenById, xenServers } = createXenServers()
|
||||
await xenServers.connectXenServer('server-1')
|
||||
|
||||
await xapis[0].disconnect()
|
||||
await sleep(DELAY * 1.5)
|
||||
assert.deepEqual(lastSeenById(), {})
|
||||
})
|
||||
|
||||
it('are removed on a deliberate disconnection', async function () {
|
||||
const { lastSeenById, xenServers } = createXenServers()
|
||||
await xenServers.connectXenServer('server-1')
|
||||
|
||||
await xenServers.disconnectXenServer('server-1')
|
||||
assert.deepEqual(lastSeenById(), {})
|
||||
})
|
||||
|
||||
it('are removed on a deliberate disconnection of a server already lost', async function () {
|
||||
const { lastSeenById, xenServers } = createXenServers()
|
||||
await xenServers.connectXenServer('server-1')
|
||||
|
||||
await xapis[0].disconnect()
|
||||
await sleep(10)
|
||||
await xenServers.disconnectXenServer('server-1')
|
||||
assert.deepEqual(lastSeenById(), {})
|
||||
})
|
||||
})
|
||||
|
||||
describe('objects fetch on connection', function () {
|
||||
it('waits for the objects up to their own timeout, not the disconnection delay', async function () {
|
||||
const { lastSeenById, xenServers } = createXenServers({ durations: { xapiObjectsFetchTimeout: DELAY * 5 } })
|
||||
nextObjectsFetchDuration = DELAY * 2
|
||||
|
||||
await xenServers.connectXenServer('server-1')
|
||||
assert.deepEqual(lastSeenById(), { 'pool-1': undefined, 'vm-1': undefined, 'vm-2': undefined })
|
||||
})
|
||||
|
||||
it('fails when the objects take longer than their timeout', async function () {
|
||||
const { xenServers } = createXenServers({
|
||||
durations: { xapiMarkDisconnectedDelay: DELAY * 5, xapiObjectsFetchTimeout: DELAY },
|
||||
})
|
||||
nextObjectsFetchDuration = DELAY * 2
|
||||
|
||||
await assert.rejects(xenServers.connectXenServer('server-1'), {
|
||||
data: { objectId: 'server-1', code: 'TIMEOUT_CONNECT_SERVER' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('server error', function () {
|
||||
it('is not reported while a quiet pool returns no events', async function () {
|
||||
const { xenServers } = createXenServers()
|
||||
await xenServers.connectXenServer('server-1')
|
||||
|
||||
// `event.from` may wait longer than `xapiMarkDisconnectedDelay`
|
||||
xapis[0].watchEventsError = undefined
|
||||
const server = await xenServers.getXenServer('server-1')
|
||||
assert.equal(server.error, undefined)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { isSrWritable } from '../../utils.mjs'
|
||||
import { buildSrContext, buildBackupRepositoryContext } from '@xen-orchestra/backups/_buildContext.mjs'
|
||||
import { compileExpression } from '@xen-orchestra/backups/_expressionPredicate.mjs'
|
||||
|
||||
export function resolveBackupSrs(job, srs, runContext) {
|
||||
const predicate = compileExpression(job.srs)
|
||||
|
||||
const srUuids = []
|
||||
srs.forEach(sr => {
|
||||
// the pool of a stale SR is disconnected: it cannot receive anything
|
||||
if (sr.$lastSeen === undefined && isSrWritable(sr) && predicate(buildSrContext(sr, runContext))) {
|
||||
srUuids.push(sr.uuid)
|
||||
}
|
||||
})
|
||||
|
||||
return srUuids
|
||||
}
|
||||
|
||||
export function resolveBackupRepositories(job, backupRepositories, runContext) {
|
||||
const predicate = compileExpression(job.remotes)
|
||||
|
||||
const backupRepositoryIds = []
|
||||
const proxySkippedIds = []
|
||||
const invalidUrlIds = []
|
||||
backupRepositories.forEach(backupRepository => {
|
||||
if (!backupRepository.enabled) return
|
||||
|
||||
const context = buildBackupRepositoryContext(backupRepository, runContext)
|
||||
if (predicate(context)) {
|
||||
if (context.backupRepository.type !== undefined) {
|
||||
if (backupRepository.proxy === job.proxy) {
|
||||
backupRepositoryIds.push(backupRepository.id)
|
||||
} else {
|
||||
proxySkippedIds.push(backupRepository.id)
|
||||
}
|
||||
} else {
|
||||
invalidUrlIds.push(backupRepository.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { backupRepositoryIds, proxySkippedIds, invalidUrlIds }
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { buildRunContext } from '@xen-orchestra/backups/_buildContext.mjs'
|
||||
|
||||
import { resolveBackupRepositories, resolveBackupSrs } from './_resolveBackupTargets.mjs'
|
||||
|
||||
// a Monday, at 22:00 UTC
|
||||
const RUN_CONTEXT = buildRunContext(new Date('2026-11-02T22:00:00Z'), 'UTC')
|
||||
|
||||
const sr = ({ uuid, name_label = uuid, SR_type = 'nfs', content_type = 'user', size = 100, tags = [] }) => ({
|
||||
uuid,
|
||||
name_label,
|
||||
name_description: '',
|
||||
SR_type,
|
||||
content_type,
|
||||
size,
|
||||
tags,
|
||||
})
|
||||
|
||||
const backupRepository = ({ id, name = id, url = 'nfs://nas:/backups', enabled = true, proxy }) => ({
|
||||
id,
|
||||
name,
|
||||
url,
|
||||
enabled,
|
||||
proxy,
|
||||
})
|
||||
|
||||
describe('resolveBackupSrs()', () => {
|
||||
it('returns the SRs matching the expression', () => {
|
||||
const srs = [sr({ uuid: 'nfs-sr' }), sr({ uuid: 'local-sr', SR_type: 'ext' })]
|
||||
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'sr:type:nfs' }, srs, RUN_CONTEXT), ['nfs-sr'])
|
||||
})
|
||||
|
||||
it('never returns an ISO or empty SR', () => {
|
||||
const srs = [sr({ uuid: 'writable' }), sr({ uuid: 'iso', content_type: 'iso' }), sr({ uuid: 'empty', size: 0 })]
|
||||
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'sr:type:nfs' }, srs, RUN_CONTEXT), ['writable'])
|
||||
})
|
||||
|
||||
it('never returns an SR of a disconnected pool', () => {
|
||||
const srs = [sr({ uuid: 'live' }), { ...sr({ uuid: 'stale' }), $lastSeen: Date.now() }]
|
||||
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'sr:type:nfs' }, srs, RUN_CONTEXT), ['live'])
|
||||
})
|
||||
|
||||
it('evaluates the run context', () => {
|
||||
const srs = [sr({ uuid: 'nfs-sr' })]
|
||||
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'run:hour:22' }, srs, RUN_CONTEXT), ['nfs-sr'])
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'run:hour:23' }, srs, RUN_CONTEXT), [])
|
||||
})
|
||||
|
||||
it('returns an empty array when nothing matches', () => {
|
||||
const srs = [sr({ uuid: 'nfs-sr' })]
|
||||
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'sr:name_label:missing' }, srs, RUN_CONTEXT), [])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveBackupRepositories()', () => {
|
||||
it('returns the backup repositories matching the expression', () => {
|
||||
const backupRepositories = [
|
||||
backupRepository({ id: 'nfs-br' }),
|
||||
backupRepository({ id: 'local-br', url: 'file:///media/backups' }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories({ remotes: 'backupRepository:type:nfs' }, backupRepositories, RUN_CONTEXT),
|
||||
{
|
||||
backupRepositoryIds: ['nfs-br'],
|
||||
proxySkippedIds: [],
|
||||
invalidUrlIds: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('skips a disabled backup repository silently', () => {
|
||||
const backupRepositories = [
|
||||
backupRepository({ id: 'enabled' }),
|
||||
backupRepository({ id: 'disabled', enabled: false }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories({ remotes: 'backupRepository:type:nfs' }, backupRepositories, RUN_CONTEXT),
|
||||
{
|
||||
backupRepositoryIds: ['enabled'],
|
||||
proxySkippedIds: [],
|
||||
invalidUrlIds: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('skips a matching backup repository linked to another proxy', () => {
|
||||
const backupRepositories = [
|
||||
backupRepository({ id: 'same-proxy', proxy: 'proxy-1' }),
|
||||
backupRepository({ id: 'other-proxy', proxy: 'proxy-2' }),
|
||||
backupRepository({ id: 'no-proxy' }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories(
|
||||
{ remotes: 'backupRepository:type:nfs', proxy: 'proxy-1' },
|
||||
backupRepositories,
|
||||
RUN_CONTEXT
|
||||
),
|
||||
{ backupRepositoryIds: ['same-proxy'], proxySkippedIds: ['other-proxy', 'no-proxy'], invalidUrlIds: [] }
|
||||
)
|
||||
})
|
||||
|
||||
it('skips a matching backup repository whose URL cannot be parsed', () => {
|
||||
const backupRepositories = [
|
||||
backupRepository({ id: 'nfs-br' }),
|
||||
backupRepository({ id: 'invalid', url: 'foo://bar' }),
|
||||
]
|
||||
|
||||
// without the URL check, the negation would pick the invalid repository, its type being undefined
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories({ remotes: '!backupRepository:type:nfs' }, backupRepositories, RUN_CONTEXT),
|
||||
{
|
||||
backupRepositoryIds: [],
|
||||
proxySkippedIds: [],
|
||||
invalidUrlIds: ['invalid'],
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('does not report an invalid backup repository the expression does not match', () => {
|
||||
const backupRepositories = [
|
||||
backupRepository({ id: 'nfs-br' }),
|
||||
backupRepository({ id: 'invalid', url: 'foo://bar' }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories({ remotes: 'backupRepository:name:nfs-br' }, backupRepositories, RUN_CONTEXT),
|
||||
{
|
||||
backupRepositoryIds: ['nfs-br'],
|
||||
proxySkippedIds: [],
|
||||
invalidUrlIds: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty arrays when nothing matches', () => {
|
||||
const backupRepositories = [backupRepository({ id: 'nfs-br' })]
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories({ remotes: 'backupRepository:type:s3' }, backupRepositories, RUN_CONTEXT),
|
||||
{
|
||||
backupRepositoryIds: [],
|
||||
proxySkippedIds: [],
|
||||
invalidUrlIds: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -52,6 +52,9 @@ export default class XenServers {
|
||||
this._connectingXenServers = new Set()
|
||||
this._reconnectingXenServers = new Set()
|
||||
this._serverIdsByPool = { __proto__: null }
|
||||
// objects kept, flagged with `$lastSeen`, after the loss of the connection
|
||||
// which provided them, by server id: `{ timeout, xoIds }`
|
||||
this._staleObjects = { __proto__: null }
|
||||
this._stats = new XapiStats()
|
||||
this._xapis = { __proto__: null }
|
||||
this._app = app
|
||||
@@ -59,6 +62,12 @@ export default class XenServers {
|
||||
app.config.watchDuration('xapiMarkDisconnectedDelay', xapiMarkDisconnectedDelay => {
|
||||
this._xapiMarkDisconnectedDelay = xapiMarkDisconnectedDelay
|
||||
})
|
||||
app.config.watchDuration('xapiObjectsFetchTimeout', xapiObjectsFetchTimeout => {
|
||||
this._xapiObjectsFetchTimeout = xapiObjectsFetchTimeout
|
||||
})
|
||||
app.config.watchDuration('xapiStaleObjectsMaxAge', xapiStaleObjectsMaxAge => {
|
||||
this._xapiStaleObjectsMaxAge = xapiStaleObjectsMaxAge
|
||||
})
|
||||
|
||||
app.hooks.on('clean', () => this._servers.rebuildIndexes())
|
||||
|
||||
@@ -283,6 +292,11 @@ export default class XenServers {
|
||||
|
||||
const serverId = this._serverIdsByPool[poolId]
|
||||
if (serverId === undefined) {
|
||||
if (object.$lastSeen !== undefined) {
|
||||
throw new Error(
|
||||
`the pool of object ${object.id} is disconnected, last seen at ${new Date(object.$lastSeen).toISOString()}`
|
||||
)
|
||||
}
|
||||
throw new Error(`no connection found for object ${object.id}`)
|
||||
}
|
||||
return serverId
|
||||
@@ -339,7 +353,8 @@ export default class XenServers {
|
||||
xapiIdsToXo[xapiId] = xoId
|
||||
|
||||
const previous = objects.get(xoId, undefined)
|
||||
if (previous && previous._xapiRef !== $ref) {
|
||||
// a stale object is replaced by the one of the new connection
|
||||
if (previous && previous._xapiRef !== $ref && previous.$lastSeen === undefined) {
|
||||
const conflicts_ = conflicts[xoId] || (conflicts[xoId] = { __proto__: null })
|
||||
conflicts_[conId] = xoObject
|
||||
} else {
|
||||
@@ -395,6 +410,39 @@ export default class XenServers {
|
||||
})
|
||||
}
|
||||
|
||||
// keeps the (already flagged) objects of a lost connection until the server
|
||||
// reconnects, or at most `xapiStaleObjectsMaxAge`
|
||||
_keepStaleObjects(serverId, xoIds) {
|
||||
let stale = this._staleObjects[serverId]
|
||||
if (stale === undefined) {
|
||||
stale = this._staleObjects[serverId] = {
|
||||
timeout: setTimeout(() => this._purgeStaleObjects(serverId), this._xapiStaleObjectsMaxAge),
|
||||
xoIds: new Set(),
|
||||
}
|
||||
}
|
||||
for (const xoId of xoIds) {
|
||||
stale.xoIds.add(xoId)
|
||||
}
|
||||
}
|
||||
|
||||
// removes the objects of a lost connection which have not been provided again
|
||||
// since, i.e. which are still flagged
|
||||
_purgeStaleObjects(serverId) {
|
||||
const stale = this._staleObjects[serverId]
|
||||
if (stale === undefined) {
|
||||
return
|
||||
}
|
||||
delete this._staleObjects[serverId]
|
||||
clearTimeout(stale.timeout)
|
||||
|
||||
const objects = this._app._objects
|
||||
for (const xoId of stale.xoIds) {
|
||||
if (objects.get(xoId, undefined)?.$lastSeen !== undefined) {
|
||||
objects.unset(xoId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// starts a reconnection loop for a server left `enabled` but without a
|
||||
// Xapi instance, a state nothing else retries (see _xenServerAutoReconnect.mjs)
|
||||
_autoReconnectXenServer(id) {
|
||||
@@ -477,6 +525,11 @@ export default class XenServers {
|
||||
watchEvents: false,
|
||||
}))
|
||||
|
||||
// set by a failed attempt to reconnect a lost server, whose objects are kept
|
||||
// flagged: the objects this attempt has fetched so far replaced some of them,
|
||||
// and must be kept, flagged, like the others, not removed
|
||||
let keepObjectsOnFailure = false
|
||||
|
||||
try {
|
||||
await xapi.connect()
|
||||
|
||||
@@ -589,17 +642,40 @@ export default class XenServers {
|
||||
markPool()
|
||||
markPoolHandle = setInterval(markPool, poolMarkingInterval)
|
||||
},
|
||||
uninstall() {
|
||||
// stops following the XAPI objects, without removing the XO ones
|
||||
detach() {
|
||||
clearInterval(markPoolHandle)
|
||||
|
||||
objects.removeListener('add', onAddOrUpdate)
|
||||
objects.removeListener('update', onAddOrUpdate)
|
||||
objects.removeListener('remove', onRemove)
|
||||
objects.removeListener('finish', onFinish)
|
||||
|
||||
},
|
||||
uninstall() {
|
||||
this.detach()
|
||||
onRemove(objects.all)
|
||||
},
|
||||
|
||||
getXoIds: () => Object.values(xapiIdsToXo),
|
||||
|
||||
// keeps the XO objects but flags them with the last time the pool
|
||||
// was successfully reached, instead of removing them
|
||||
markStale: () => {
|
||||
const lastSeen = xapi.lastEventFetchedTimestamp ?? Date.now()
|
||||
const xoObjects = this._app._objects
|
||||
for (const xoId of Object.values(xapiIdsToXo)) {
|
||||
const object = xoObjects.get(xoId, undefined)
|
||||
if (object !== undefined && object.$lastSeen === undefined) {
|
||||
xoObjects.set(xoId, { ...object, $lastSeen: lastSeen })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// replays the current XAPI objects, which replaces the flagged ones
|
||||
refresh() {
|
||||
onAddOrUpdate(objects.all)
|
||||
},
|
||||
|
||||
addObject,
|
||||
getData: (id, key) => {
|
||||
const value = (typeof id === 'string' ? xapi.getObject(id) : id).other_config[`xo:${camelToSnakeCase(key)}`]
|
||||
@@ -636,9 +712,22 @@ export default class XenServers {
|
||||
// this server, or stop the auto-reconnect loop which waits for
|
||||
// `disconnected`
|
||||
let markDisconnectedTimeout
|
||||
// after the grace period, the objects are kept but flagged as stale, then
|
||||
// removed once `xapiStaleObjectsMaxAge` is reached
|
||||
let removeStaleTimeout
|
||||
// the pool cannot be used to know whether it has been marked: the
|
||||
// listeners are still installed and a refetch of the objects (e.g. after
|
||||
// a failover) registers it again
|
||||
let marked = false
|
||||
let staleRemoved = false
|
||||
const onEventFetchingError = () => {
|
||||
markDisconnectedTimeout = setTimeout(() => {
|
||||
xapi.xo.uninstall()
|
||||
marked = true
|
||||
xapi.xo.markStale()
|
||||
removeStaleTimeout = setTimeout(() => {
|
||||
xapi.xo.uninstall()
|
||||
staleRemoved = true
|
||||
}, this._xapiStaleObjectsMaxAge)
|
||||
|
||||
// switch server status from connected to connecting
|
||||
this._forgetXenServerPool(server.id)
|
||||
@@ -646,11 +735,19 @@ export default class XenServers {
|
||||
}, this._xapiMarkDisconnectedDelay)
|
||||
xapi.once('eventFetchingSuccess', () => {
|
||||
xapi.once('eventFetchingError', onEventFetchingError)
|
||||
if (serverIdsByPool[poolId] === undefined) {
|
||||
// the pool may now be known under another identifier, `install()`
|
||||
// replays the pool object which registers it under the right one
|
||||
if (marked) {
|
||||
marked = false
|
||||
clearTimeout(removeStaleTimeout)
|
||||
|
||||
// the pool may now be known under another identifier, replaying
|
||||
// the pool object registers it under the right one
|
||||
serverIdsByPool[poolId] = server.id
|
||||
xapi.xo.install()
|
||||
if (staleRemoved) {
|
||||
staleRemoved = false
|
||||
xapi.xo.install()
|
||||
} else {
|
||||
xapi.xo.refresh()
|
||||
}
|
||||
_updateXenServer(server.id, { error: null, status: 'connected' })
|
||||
} else {
|
||||
clearTimeout(markDisconnectedTimeout)
|
||||
@@ -661,7 +758,20 @@ export default class XenServers {
|
||||
|
||||
xapi.once('disconnected', async () => {
|
||||
clearTimeout(markDisconnectedTimeout)
|
||||
xapi.xo.uninstall()
|
||||
clearTimeout(removeStaleTimeout)
|
||||
|
||||
// `disconnectXenServer` and failed connections forget the Xapi
|
||||
// beforehand: deliberate disconnections and failed first connections
|
||||
// remove the objects, the others, including a failed attempt to
|
||||
// reconnect a lost server, keep them, flagged, until the server
|
||||
// reconnects
|
||||
if (this._xapis[server.id] === xapi || keepObjectsOnFailure) {
|
||||
xapi.xo.markStale()
|
||||
xapi.xo.detach()
|
||||
this._keepStaleObjects(server.id, xapi.xo.getXoIds())
|
||||
} else {
|
||||
xapi.xo.uninstall()
|
||||
}
|
||||
delete this._xapis[server.id]
|
||||
this._forgetXenServerPool(server.id)
|
||||
this._app.emit('server:disconnected', { server, xapi })
|
||||
@@ -676,18 +786,22 @@ export default class XenServers {
|
||||
})
|
||||
|
||||
try {
|
||||
await timeout.call(xapi._interruptOnDisconnect(xapi.objectsFetched), this._xapiMarkDisconnectedDelay)
|
||||
await timeout.call(xapi._interruptOnDisconnect(xapi.objectsFetched), this._xapiObjectsFetchTimeout)
|
||||
} catch (error) {
|
||||
if (!(error instanceof TimeoutError)) {
|
||||
throw error
|
||||
}
|
||||
log.warn('objects take too long to fetch', { id, xapiMarkDisconnectedDelay: this._xapiMarkDisconnectedDelay })
|
||||
log.warn('objects take too long to fetch', { id, xapiObjectsFetchTimeout: this._xapiObjectsFetchTimeout })
|
||||
throw operationFailed({ objectId: id, code: 'TIMEOUT_CONNECT_SERVER' })
|
||||
}
|
||||
|
||||
// objects of the previous connection which no longer exist
|
||||
this._purgeStaleObjects(server.id)
|
||||
|
||||
this._app.emit('server:connected', { server, xapi })
|
||||
await this.updateXenServer(id, { error: null, status: 'connected' })::ignoreErrors()
|
||||
} catch (error) {
|
||||
keepObjectsOnFailure = this._staleObjects[server.id] !== undefined
|
||||
delete this._xapis[server.id]
|
||||
await xapi.disconnect()::ignoreErrors()
|
||||
|
||||
@@ -759,6 +873,7 @@ export default class XenServers {
|
||||
this._forgetXenServerPool(id)
|
||||
|
||||
await xapi?.disconnect()
|
||||
this._purgeStaleObjects(id)
|
||||
if (status !== 'disconnected') {
|
||||
await this.updateXenServer(id, { status: 'disconnected' })
|
||||
}
|
||||
@@ -783,7 +898,9 @@ export default class XenServers {
|
||||
const xapis = this._xapis
|
||||
|
||||
const lastEventFetchedTimestamp = xapis[server.id]?.lastEventFetchedTimestamp
|
||||
// a quiet pool may not return events for longer than this delay
|
||||
if (
|
||||
xapis[server.id]?.watchEventsError !== undefined &&
|
||||
lastEventFetchedTimestamp !== undefined &&
|
||||
Date.now() > lastEventFetchedTimestamp + this._xapiMarkDisconnectedDelay &&
|
||||
xapis[server.id].watchEventsError !== undefined
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user