mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
3 Commits
8025af3cc8
...
docs/forge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
398ea36537 | ||
|
|
c91194c374 | ||
|
|
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]
|
||||
@@ -552,8 +552,8 @@ export type XoVmBackupJobGeneralSettings = XoBackupJobGeneralSettings & {
|
||||
}
|
||||
export type XoVmBackupJobScheduleSettings = {
|
||||
exportRetention?: number
|
||||
healthCheckVmsWithTags?: XoVm['tags']
|
||||
fullInterval?: number
|
||||
healthCheckVmsWithTags?: XoVm['tags'] | string
|
||||
fullInterval?: number | string
|
||||
copyRetention?: number
|
||||
snapshotRetention?: number
|
||||
cbtDestroySnapshotData?: boolean
|
||||
@@ -564,9 +564,9 @@ export type XoVmBackupJob = BaseXoJob & {
|
||||
compression?: 'native' | 'zstd' | ''
|
||||
proxy?: XoProxy['id']
|
||||
mode: 'full' | 'delta'
|
||||
remotes?: CMType.IdOr<XoBackupRepository['id']>
|
||||
remotes?: CMType.IdOr<XoBackupRepository['id']> | string
|
||||
vms: CMType.IdOr<XoVm['id']> | Record<string, unknown>
|
||||
srs?: CMType.IdOr<XoSr['id']>
|
||||
srs?: CMType.IdOr<XoSr['id']> | string
|
||||
type: 'backup'
|
||||
settings: {
|
||||
'': XoVmBackupJobGeneralSettings
|
||||
@@ -598,13 +598,15 @@ export type XoMirrorBackupGeneralSettings = XoBackupJobGeneralSettings & {
|
||||
nRetriesVmBackupFailures?: number
|
||||
mergeBackupsSynchronously?: boolean
|
||||
timeout?: number
|
||||
timezone?: string
|
||||
maxExportRate?: number
|
||||
backupReportTpl?: 'compactMjml'
|
||||
reportWhen: 'failure'
|
||||
}
|
||||
export type XoMirrorBackupScheduleSettings = {
|
||||
exportRetention?: number
|
||||
healthCheckVmsWithTags?: XoVm['tags']
|
||||
healthCheckVmsWithTags?: XoVm['tags'] | string
|
||||
vmFilter?: string
|
||||
healthCheckSr?: XoSr['id']
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
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'][] = []
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
|
||||
<!--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-web minor
|
||||
|
||||
<!--packages-end-->
|
||||
|
||||
@@ -789,6 +789,74 @@ To remove one host from a pool, you can go to the "Advanced" tab of the host pag
|
||||
- The host you want to remove must be a slave, not the master!
|
||||
:::
|
||||
|
||||
### Forget a host that is permanently gone
|
||||
|
||||
Detaching only works on a host that is up and reachable. When a host is dead for good (hardware failure, stolen, reinstalled from scratch…), it stays in the pool database as an offline host, and the pool keeps waiting for it. In that case, **forget** the host: this removes its record from the pool database (XAPI `host.destroy`).
|
||||
|
||||
:::danger
|
||||
Only forget a host that will never come back online with its current installation. If the host can be repaired and booted again, fix it and use [Detach](#remove-a-host-from-an-existing-pool) instead. Forgetting a host cannot be undone.
|
||||
:::
|
||||
|
||||
#### Before forgetting the host
|
||||
|
||||
1. **Make sure the host is really off.** Xen Orchestra only offers the Forget action when the host is not running. If the machine is still powered on but unreachable, power it off (for example through its IPMI/BMC) before going further.
|
||||
|
||||
2. **If the dead host was the pool master**, promote another host first. The pool cannot be managed without a master. On the host that will become the new master, run:
|
||||
|
||||
```sh
|
||||
xe pool-emergency-transition-to-master
|
||||
xe pool-recover-slaves
|
||||
```
|
||||
|
||||
Then reconnect Xen Orchestra to the new master if needed (Settings > Servers).
|
||||
|
||||
3. **Clear the VMs still recorded on the host.** If some VMs were running on the host when it died, the pool still considers them as running there, and forgetting the host fails with this error:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "HOST_HAS_RESIDENT_VMS",
|
||||
"params": ["OpaqueRef:…"]
|
||||
}
|
||||
```
|
||||
|
||||
List these VMs from a surviving host of the pool:
|
||||
|
||||
```sh
|
||||
xe vm-list resident-on=<dead host UUID> is-control-domain=false params=uuid,name-label,power-state
|
||||
```
|
||||
|
||||
Then mark each of them as halted:
|
||||
|
||||
```sh
|
||||
xe vm-reset-powerstate uuid=<VM UUID> --force
|
||||
```
|
||||
|
||||
:::warning
|
||||
`vm-reset-powerstate` tells the pool that the VM is stopped, without checking. If the host is in fact still running that VM and you start it on another host, the VM runs twice on the same disks, which corrupts them. Only run this command once you are sure the dead host is powered off.
|
||||
:::
|
||||
|
||||
VMs whose disks are on a shared SR can then be started on another host. VMs whose disks were on the local storage of the dead host cannot be started anymore: restore them from your backups.
|
||||
|
||||
#### Forget the host
|
||||
|
||||
Once the host is halted and has no resident VMs left, forget it from Xen Orchestra:
|
||||
|
||||
- **XO 5**: on the host page, go to the **Advanced** tab and click **Forget**. The button is only shown when the host is not running.
|
||||
- **XO 6**: on the host page, open the actions menu (**⋮**) and select **Forget**. The action is only enabled when the host is halted.
|
||||
|
||||
You can also forget it from the command line, on the pool master:
|
||||
|
||||
```sh
|
||||
xe host-forget uuid=<dead host UUID>
|
||||
```
|
||||
|
||||
For the XCP-ng side of removing a host from a pool (`pool-eject` versus `host-forget`), see [Remove a host from a pool](https://docs.xcp-ng.org/management/hosts-pools/#remove-a-host-from-a-pool) in the XCP-ng documentation.
|
||||
|
||||
#### After forgetting the host
|
||||
|
||||
- The local SRs of the forgotten host (local storage, removable devices…) can stay behind with no host attached. Once you made sure you don't need them anymore, forget them from the **Storage** view.
|
||||
- If you want to reuse the same machine, reinstall XCP-ng on it, then add it to the pool like any new host (see [Adding a host to an existing pool](#adding-a-host-to-an-existing-pool)).
|
||||
|
||||
### Network bonding
|
||||
|
||||
#### Definition
|
||||
|
||||
@@ -39,6 +39,8 @@ Two host tabs deserve a special mention:
|
||||
|
||||
The remaining tabs (System, Network, Storage, Tasks, VMs) follow the same patterns as pools.
|
||||
|
||||
To remove a host that is permanently gone from its pool, use **Forget** in the host action menu. The action is only enabled when the host is halted, and some prerequisites must be handled from the command line: read [Forget a host that is permanently gone](manage_infrastructure.md#forget-a-host-that-is-permanently-gone) first.
|
||||
|
||||
## VMs
|
||||
|
||||
The VM page is where you will spend most of your time. Its dashboard gathers the quick info (state, IP address, OS, vCPUs, RAM, virtualization mode, guest tools version and tags), the last backup runs and the protection status, the last replication, and the VM alarms.
|
||||
|
||||
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,42 @@
|
||||
import { isSrWritable } from '../../utils.mjs'
|
||||
import { buildSrContext, buildBackupRepositoryContext } from '@xen-orchestra/backups/_buildContext.mjs'
|
||||
import { compileExpression } from '@xen-orchestra/backups/_expressionPredicate.mjs'
|
||||
|
||||
export function resolveBackupSrs(job, srs, runContext) {
|
||||
const predicate = compileExpression(job.srs)
|
||||
|
||||
const srUuids = []
|
||||
srs.forEach(sr => {
|
||||
if (isSrWritable(sr) && predicate(buildSrContext(sr, runContext))) {
|
||||
srUuids.push(sr.uuid)
|
||||
}
|
||||
})
|
||||
|
||||
return srUuids
|
||||
}
|
||||
|
||||
export function resolveBackupRepositories(job, backupRepositories, runContext) {
|
||||
const predicate = compileExpression(job.remotes)
|
||||
|
||||
const backupRepositoryIds = []
|
||||
const proxySkippedIds = []
|
||||
const invalidUrlIds = []
|
||||
backupRepositories.forEach(backupRepository => {
|
||||
if (!backupRepository.enabled) return
|
||||
|
||||
const context = buildBackupRepositoryContext(backupRepository, runContext)
|
||||
if (predicate(context)) {
|
||||
if (context.backupRepository.type !== undefined) {
|
||||
if (backupRepository.proxy === job.proxy) {
|
||||
backupRepositoryIds.push(backupRepository.id)
|
||||
} else {
|
||||
proxySkippedIds.push(backupRepository.id)
|
||||
}
|
||||
} else {
|
||||
invalidUrlIds.push(backupRepository.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { backupRepositoryIds, proxySkippedIds, invalidUrlIds }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { buildRunContext } from '@xen-orchestra/backups/_buildContext.mjs'
|
||||
|
||||
import { resolveBackupRepositories, resolveBackupSrs } from './_resolveBackupTargets.mjs'
|
||||
|
||||
// a Monday, at 22:00 UTC
|
||||
const RUN_CONTEXT = buildRunContext(new Date('2026-11-02T22:00:00Z'), 'UTC')
|
||||
|
||||
const sr = ({ uuid, name_label = uuid, SR_type = 'nfs', content_type = 'user', size = 100, tags = [] }) => ({
|
||||
uuid,
|
||||
name_label,
|
||||
name_description: '',
|
||||
SR_type,
|
||||
content_type,
|
||||
size,
|
||||
tags,
|
||||
})
|
||||
|
||||
const backupRepository = ({ id, name = id, url = 'nfs://nas:/backups', enabled = true, proxy }) => ({
|
||||
id,
|
||||
name,
|
||||
url,
|
||||
enabled,
|
||||
proxy,
|
||||
})
|
||||
|
||||
describe('resolveBackupSrs()', () => {
|
||||
it('returns the SRs matching the expression', () => {
|
||||
const srs = [sr({ uuid: 'nfs-sr' }), sr({ uuid: 'local-sr', SR_type: 'ext' })]
|
||||
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'sr:type:nfs' }, srs, RUN_CONTEXT), ['nfs-sr'])
|
||||
})
|
||||
|
||||
it('never returns an ISO or empty SR', () => {
|
||||
const srs = [sr({ uuid: 'writable' }), sr({ uuid: 'iso', content_type: 'iso' }), sr({ uuid: 'empty', size: 0 })]
|
||||
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'sr:type:nfs' }, srs, RUN_CONTEXT), ['writable'])
|
||||
})
|
||||
|
||||
it('evaluates the run context', () => {
|
||||
const srs = [sr({ uuid: 'nfs-sr' })]
|
||||
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'run:hour:22' }, srs, RUN_CONTEXT), ['nfs-sr'])
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'run:hour:23' }, srs, RUN_CONTEXT), [])
|
||||
})
|
||||
|
||||
it('returns an empty array when nothing matches', () => {
|
||||
const srs = [sr({ uuid: 'nfs-sr' })]
|
||||
|
||||
assert.deepEqual(resolveBackupSrs({ srs: 'sr:name_label:missing' }, srs, RUN_CONTEXT), [])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveBackupRepositories()', () => {
|
||||
it('returns the backup repositories matching the expression', () => {
|
||||
const backupRepositories = [
|
||||
backupRepository({ id: 'nfs-br' }),
|
||||
backupRepository({ id: 'local-br', url: 'file:///media/backups' }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories({ remotes: 'backupRepository:type:nfs' }, backupRepositories, RUN_CONTEXT),
|
||||
{
|
||||
backupRepositoryIds: ['nfs-br'],
|
||||
proxySkippedIds: [],
|
||||
invalidUrlIds: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('skips a disabled backup repository silently', () => {
|
||||
const backupRepositories = [
|
||||
backupRepository({ id: 'enabled' }),
|
||||
backupRepository({ id: 'disabled', enabled: false }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories({ remotes: 'backupRepository:type:nfs' }, backupRepositories, RUN_CONTEXT),
|
||||
{
|
||||
backupRepositoryIds: ['enabled'],
|
||||
proxySkippedIds: [],
|
||||
invalidUrlIds: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('skips a matching backup repository linked to another proxy', () => {
|
||||
const backupRepositories = [
|
||||
backupRepository({ id: 'same-proxy', proxy: 'proxy-1' }),
|
||||
backupRepository({ id: 'other-proxy', proxy: 'proxy-2' }),
|
||||
backupRepository({ id: 'no-proxy' }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories(
|
||||
{ remotes: 'backupRepository:type:nfs', proxy: 'proxy-1' },
|
||||
backupRepositories,
|
||||
RUN_CONTEXT
|
||||
),
|
||||
{ backupRepositoryIds: ['same-proxy'], proxySkippedIds: ['other-proxy', 'no-proxy'], invalidUrlIds: [] }
|
||||
)
|
||||
})
|
||||
|
||||
it('skips a matching backup repository whose URL cannot be parsed', () => {
|
||||
const backupRepositories = [
|
||||
backupRepository({ id: 'nfs-br' }),
|
||||
backupRepository({ id: 'invalid', url: 'foo://bar' }),
|
||||
]
|
||||
|
||||
// without the URL check, the negation would pick the invalid repository, its type being undefined
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories({ remotes: '!backupRepository:type:nfs' }, backupRepositories, RUN_CONTEXT),
|
||||
{
|
||||
backupRepositoryIds: [],
|
||||
proxySkippedIds: [],
|
||||
invalidUrlIds: ['invalid'],
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('does not report an invalid backup repository the expression does not match', () => {
|
||||
const backupRepositories = [
|
||||
backupRepository({ id: 'nfs-br' }),
|
||||
backupRepository({ id: 'invalid', url: 'foo://bar' }),
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories({ remotes: 'backupRepository:name:nfs-br' }, backupRepositories, RUN_CONTEXT),
|
||||
{
|
||||
backupRepositoryIds: ['nfs-br'],
|
||||
proxySkippedIds: [],
|
||||
invalidUrlIds: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty arrays when nothing matches', () => {
|
||||
const backupRepositories = [backupRepository({ id: 'nfs-br' })]
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBackupRepositories({ remotes: 'backupRepository:type:s3' }, backupRepositories, RUN_CONTEXT),
|
||||
{
|
||||
backupRepositoryIds: [],
|
||||
proxySkippedIds: [],
|
||||
invalidUrlIds: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -22,6 +22,9 @@ import { serializeError, unboxIdsFromPattern } from '../../utils.mjs'
|
||||
import { serveVmBackups, VmBackupsCache } from './_vmBackupsCache.mjs'
|
||||
import { VmBackupsSource } from './_vmBackupsSource.mjs'
|
||||
import { waitAll } from '../../_waitAll.mjs'
|
||||
import { isExpression } from '@xen-orchestra/backups/_expressionPredicate.mjs'
|
||||
import { buildRunContext } from '@xen-orchestra/backups/_buildContext.mjs'
|
||||
import { resolveBackupSrs, resolveBackupRepositories } from './_resolveBackupTargets.mjs'
|
||||
|
||||
const logger = createLogger('xo:xo-mixins:backups-ng')
|
||||
|
||||
@@ -190,7 +193,68 @@ export default class BackupNg {
|
||||
|
||||
let vmIds
|
||||
|
||||
const timezone = job.settings[schedule.id]?.timezone ?? job.settings['']?.timezone ?? schedule.timezone
|
||||
const runContext = buildRunContext(new Date(), timezone)
|
||||
|
||||
job = { ...job, runContext }
|
||||
|
||||
if (job.type === 'backup') {
|
||||
const srsExpression = isExpression(job.srs) ? job.srs : undefined
|
||||
const remotesExpression = isExpression(job.remotes) ? job.remotes : undefined
|
||||
|
||||
if (isExpression(job.srs)) {
|
||||
const srUuids = resolveBackupSrs(job, Object.values(app.getObjectsByType('SR')), runContext)
|
||||
|
||||
job.srs = { id: { __or: srUuids } }
|
||||
}
|
||||
|
||||
if (isExpression(job.remotes)) {
|
||||
const {
|
||||
backupRepositoryIds: remoteIds,
|
||||
proxySkippedIds,
|
||||
invalidUrlIds,
|
||||
} = resolveBackupRepositories(job, await app.getAllRemotes(), runContext)
|
||||
|
||||
if (proxySkippedIds.length > 0) {
|
||||
jobsLogger.warning(
|
||||
'some backup repositories matched the expression but are not linked to the proxy of this job, they are skipped',
|
||||
{
|
||||
event: 'task.warning',
|
||||
taskId: runJobId,
|
||||
data: { backupRepositories: proxySkippedIds, proxy: job.proxy },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (invalidUrlIds.length > 0) {
|
||||
jobsLogger.warning(
|
||||
'some backup repositories matched the expression but have an invalid URL, they are skipped',
|
||||
{
|
||||
event: 'task.warning',
|
||||
taskId: runJobId,
|
||||
data: { backupRepositories: invalidUrlIds },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
job.remotes = { id: { __or: remoteIds } }
|
||||
}
|
||||
|
||||
if (
|
||||
(srsExpression !== undefined || remotesExpression !== undefined) &&
|
||||
unboxIdsFromPattern(job.srs).length === 0 &&
|
||||
unboxIdsFromPattern(job.remotes).length === 0
|
||||
) {
|
||||
jobsLogger.warning(
|
||||
'no SR or backup repository matched the expressions, nothing will be backed up or replicated',
|
||||
{
|
||||
event: 'task.warning',
|
||||
taskId: runJobId,
|
||||
data: { srs: srsExpression, remotes: remotesExpression },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const vmsPattern = job.vms
|
||||
|
||||
// Make sure we are passing only the VM to run which can be
|
||||
@@ -255,7 +319,6 @@ export default class BackupNg {
|
||||
|
||||
job = {
|
||||
...job,
|
||||
|
||||
vms: { id: { __or: vmIds } },
|
||||
settings: merge(job.settings, data?.settings),
|
||||
}
|
||||
|
||||
@@ -241,9 +241,13 @@ export default class {
|
||||
if (idsToCheck === undefined) {
|
||||
return false
|
||||
}
|
||||
const { id } = idsToCheck
|
||||
const ids = typeof id === 'string' ? [id] : id.__or
|
||||
return ids.includes(remoteId)
|
||||
if (typeof idsToCheck === 'string') {
|
||||
return true
|
||||
} else {
|
||||
const { id } = idsToCheck
|
||||
const ids = typeof id === 'string' ? [id] : id.__or
|
||||
return ids.includes(remoteId)
|
||||
}
|
||||
}
|
||||
|
||||
// The caller of this function have to create a parent Task
|
||||
|
||||
@@ -637,6 +637,7 @@ const messages = {
|
||||
'Delete old backups before backing up the VMs. If the new backup fails, you will lose your old backups.',
|
||||
customTag: 'Custom tag',
|
||||
editJobNotFound: "The job you're trying to edit wasn't found",
|
||||
editJobUsesExpressions: 'This job uses expressions which are not supported in XO 5, edit it in XO 6',
|
||||
preferNbd: 'Use NBD to transfer disk if available',
|
||||
preferNbdInformation: 'A network accessible by XO or the proxy must have NBD enabled.',
|
||||
distributeBackups: 'Distribute backups across backup repositories',
|
||||
|
||||
@@ -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