feat(rest-api): create backup job routes (#10595)

XO-2189
XO-3282
This commit is contained in:
Florent BEAUCHAMP
2026-11-09 17:51:05 +01:00
committed by GitHub
parent abbe24631d
commit 724402733b
26 changed files with 742 additions and 468 deletions

View File

@@ -9,6 +9,8 @@ import type {
XoConfigBackupArchive,
XoGroup,
XoHost,
XoMetadataBackupJob,
XoMirrorBackupJob,
XoPlugin,
XoPool,
XoPoolBackupArchive,
@@ -21,6 +23,7 @@ import type {
XoVif,
XoVm,
XoVmBackupArchive,
XoVmBackupJob,
} from './xo.mjs'
import { VatesTask } from './lib/vates-task.mjs'
import type { PluginRestRouteDefinition } from './lib/rest-api.mjs'
@@ -309,6 +312,17 @@ export type XoApp = EventEmitter & {
set(key: string, value: unknown): void
unset(key: string): void
}
createBackupNgJob<T extends XoVmBackupJob | XoMirrorBackupJob>(
type: T['type'],
props: Omit<T, 'id' | 'type'>,
/** keyed by temporary IDs, which can be used as keys in `props.settings` */
schedules?: Record<string, Pick<XoSchedule, 'cron' | 'enabled' | 'name' | 'timezone'>>
): Promise<T>
createMetadataBackupJob(
props: Omit<XoMetadataBackupJob, 'id' | 'type'>,
/** keyed by temporary IDs, which can be used as keys in `props.settings` */
schedules: Record<string, Pick<XoSchedule, 'cron' | 'enabled' | 'name' | 'timezone'>>
): Promise<XoMetadataBackupJob>
createAuthenticationToken(opts: {
client?: {
id?: string
@@ -324,6 +338,14 @@ export type XoApp = EventEmitter & {
proxy?: XoProxy['id']
url: string
}): Promise<XoBackupRepository>
createSchedule(params: {
cron: XoSchedule['cron']
enabled: XoSchedule['enabled']
jobId: XoSchedule['jobId']
name?: XoSchedule['name']
timezone?: XoSchedule['timezone']
userId?: XoUser['id']
}): Promise<XoSchedule>
createUser(params: {
firstname?: string
lastname?: string
@@ -340,6 +362,7 @@ export type XoApp = EventEmitter & {
deleteAclV2Privilege(privilegeId: XoAclBasePrivilege['id'], options?: { force?: boolean }): Promise<boolean>
deleteAclV2Role(roleId: XoAclRole['id'], options?: { force?: boolean }): Promise<boolean>
deleteGroup(id: XoGroup['id']): Promise<void>
deleteSchedule(id: XoSchedule['id']): Promise<void>
deleteUser(id: XoUser['id']): Promise<void>
detachHostFromPool(hostId: XoHost['id']): Promise<void>
createGroup(params: { name: string; provider?: string; providerGroup?: string }): Promise<XoGroup>
@@ -378,6 +401,8 @@ export type XoApp = EventEmitter & {
getRemote(id: XoBackupRepository['id']): Promise<XoBackupRepository>
getAllRemotes(): Promise<XoBackupRepository[]>
removeRemote(id: XoBackupRepository['id']): Promise<void>
/** Also deletes the schedules of the job */
removeJob(id: AnyXoJob['id']): Promise<void>
getAllRemotesInfo(): Promise<
Record<
XoBackupRepository['id'],
@@ -543,6 +568,19 @@ export type XoApp = EventEmitter & {
): Promise<{ vmUuid: string; success: boolean; merge?: boolean; size?: number; error?: string }[]>
/** Remove a server from the DB (XCP-ng/XenServer) */
unregisterXenServer(id: XoServer['id']): Promise<void>
/**
* Shallow merge of the properties into the existing job, a `null` value removes the property.
*/
updateJob(job: { id: AnyXoJob['id']; [key: string]: unknown }, merge?: boolean): Promise<void>
updateSchedule(params: {
id: XoSchedule['id']
cron?: XoSchedule['cron']
enabled?: XoSchedule['enabled']
jobId?: XoSchedule['jobId']
name?: XoSchedule['name']
timezone?: XoSchedule['timezone']
userId?: XoUser['id']
}): Promise<void>
updateUser(
id: XoUser['id'],
updates: {

View File

@@ -530,15 +530,22 @@ type XoBackupJobGeneralSettings = {
[key: string]: unknown
}
export type XoVmBackupJobGeneralSettings = XoBackupJobGeneralSettings & {
cbtDestroySnapshotData?: boolean
concurrency?: number
longTermRetention?: {
/**
* Keeps the last backup of each of the last `retention` days/weeks/months/years, in addition to `exportRetention`.
*
* Requires the `timezone` setting.
*/
export type XoBackupJobLongTermRetention = {
daily?: { retention: number; settings: Record<string, unknown> }
weekly?: { retention: number; settings: Record<string, unknown> }
monthly?: { retention: number; settings: Record<string, unknown> }
yearly?: { retention: number; settings: Record<string, unknown> }
}
}
export type XoVmBackupJobGeneralSettings = XoBackupJobGeneralSettings & {
cbtDestroySnapshotData?: boolean
concurrency?: number
longTermRetention?: XoBackupJobLongTermRetention
checkpointSnapshot?: boolean
offlineSnapshot?: boolean
maxExportRate?: number
@@ -595,13 +602,12 @@ export type XoMetadataBackupJob = BaseXoJob & {
export type XoMirrorBackupGeneralSettings = XoBackupJobGeneralSettings & {
concurrency?: number
longTermRetention?: XoBackupJobLongTermRetention
nRetriesVmBackupFailures?: number
mergeBackupsSynchronously?: boolean
timeout?: number
timezone?: string
maxExportRate?: number
backupReportTpl?: 'compactMjml'
reportWhen: 'failure'
}
export type XoMirrorBackupScheduleSettings = {
exportRetention?: number

View File

@@ -1,4 +1,4 @@
import { isExpression, compileExpression } from '@xen-orchestra/backups/_expressionPredicate.mjs'
import { isExpression, compileExpression } from './_expressionPredicate.mjs'
import { invalidParameters } from 'xo-common/api-errors.js'
export function assertValidSettingsExpression(settings, keys) {

View File

@@ -2,7 +2,7 @@ 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'
import { assertValidExpression, assertValidSettingsExpression } from './backupExpressions.mjs'
const VALID = 'vm:tags:prod'
const INVALID = 'vm:('

View File

@@ -58,6 +58,7 @@
"value-matcher": "^0.2.0",
"vhd-lib": "^4.17.0",
"xen-api": "^5.1.0",
"xo-common": "^0.11.0",
"xo-remote-parser": "^2.0.0",
"yazl": "^2.5.1"
},

View File

@@ -163,8 +163,8 @@ yarn workspace @xen-orchestra/qa-test demo
├── backup.config.js # Backup job configuration generator
├── client/
│ ├── dispatchClient.js # Central hub: orchestrates connections and handlers
│ ├── restApiClient.js # HTTP REST API client (reads)
│ ├── xoLibClient.js # WebSocket connection via xo-lib (writes)
│ ├── restApiClient.js # HTTP REST API client
│ ├── xoLibClient.js # WebSocket connection via xo-lib (writes not in REST)
│ ├── cleanupClient.js # Automatic test resource cleanup
│ ├── FilterBuilder.js # REST API filter builder
│ └── requests/ # Specialized handlers per resource
@@ -200,8 +200,8 @@ yarn workspace @xen-orchestra/qa-test demo
The `DispatchClient` is the main entry point. It orchestrates:
- **REST API** (`restApiClient`) for reads (GET)
- **WebSocket** (`xoLibClient` via `xo-lib`) for writes (JSON-RPC calls)
- **REST API** (`restApiClient`) for reads (GET) and the writes it supports, e.g. creating, updating, deleting and running backup jobs and schedules
- **WebSocket** (`xoLibClient` via `xo-lib`) for the writes not yet exposed by the REST API (JSON-RPC calls)
Each handler in `requests/` extends `AbstractRequest` which provides generic CRUD operations with a REST-first approach.

View File

@@ -171,8 +171,8 @@ yarn workspace @xen-orchestra/qa-test demo
├── backup.config.js # Backup job configuration generator
├── client/
│ ├── dispatchClient.js # Central hub: orchestrates connections and handlers
│ ├── restApiClient.js # HTTP REST API client (reads)
│ ├── xoLibClient.js # WebSocket connection via xo-lib (writes)
│ ├── restApiClient.js # HTTP REST API client
│ ├── xoLibClient.js # WebSocket connection via xo-lib (writes not in REST)
│ ├── cleanupClient.js # Automatic test resource cleanup
│ ├── FilterBuilder.js # REST API filter builder
│ └── requests/ # Specialized handlers per resource
@@ -208,8 +208,8 @@ yarn workspace @xen-orchestra/qa-test demo
The `DispatchClient` is the main entry point. It orchestrates:
- **REST API** (`restApiClient`) for reads (GET)
- **WebSocket** (`xoLibClient` via `xo-lib`) for writes (JSON-RPC calls)
- **REST API** (`restApiClient`) for reads (GET) and the writes it supports, e.g. creating, updating, deleting and running backup jobs and schedules
- **WebSocket** (`xoLibClient` via `xo-lib`) for the writes not yet exposed by the REST API (JSON-RPC calls)
Each handler in `requests/` extends `AbstractRequest` which provides generic CRUD operations with a REST-first approach.

View File

@@ -303,30 +303,14 @@ export class CleanupClient {
}
}
// Mirror backup jobs require mirrorBackup.deleteJob, standard jobs use backupNg.deleteJob.
// The REST API may not expose the type field for mirror jobs, so fall back when backupNg
// returns "no such job" (error.data.type === 'job').
if (job.type === 'mirrorBackup') {
await this.dispatchClient.backup.deleteMirrorBackupJob(job.id)
} else {
// the REST API deletes any type of backup job, along with its schedules
try {
await this.dispatchClient.backup.deleteBackupJob(job.id)
} catch (error) {
if (error?.data?.type !== 'job') {
if (error.status !== 404) {
throw error
}
// backupNg doesn't know this job — may be a mirror backup job; try the mirror method
try {
await this.dispatchClient.backup.deleteMirrorBackupJob(job.id)
} catch (mirrorError) {
if (mirrorError?.data?.type === 'job') {
// Job is unreachable through both RPCs (orphaned from a previous run) — skip silently
log.debug('Backup job not found via any delete method, skipping', { jobId: job.id })
return
}
throw mirrorError
}
}
log.debug('Backup job already deleted, skipping', { jobId: job.id })
}
}
@@ -355,9 +339,8 @@ export class CleanupClient {
}
const findSchedules = async () => {
const allSchedules = await this.dispatchClient.xoClient.call('schedule.getAll')
const schedulesArray = Array.isArray(allSchedules) ? allSchedules : Object.values(allSchedules)
return schedulesArray.filter(
const allSchedules = await this.dispatchClient.backup.listSchedules()
return allSchedules.filter(
schedule =>
config.includeIds.includes(schedule.id) ||
config.namePatterns.some(pattern => schedule.name?.includes(pattern))
@@ -365,7 +348,7 @@ export class CleanupClient {
}
const deleteSchedule = async schedule => {
await this.dispatchClient.xoClient.call('schedule.delete', { id: schedule.id })
await this.dispatchClient.backup.deleteSchedule(schedule.id)
}
return await this._deleteResourcesBatch('schedule', findSchedules, deleteSchedule, {

View File

@@ -4,83 +4,152 @@ import { AbstractRequest } from './abstract.js'
const log = createLogger('xo:qa-test:backup')
/**
* Converts a caller-friendly `{ [id]: anything }` map to the `{ id }` pattern expected by XO.
*
* @param {Object} [objectsById]
* @returns {Object|undefined} undefined if there is no ID
*/
const toIdPattern = objectsById => {
const ids = objectsById !== null && typeof objectsById === 'object' ? Object.keys(objectsById) : []
return ids.length > 0 ? toSimplePattern(ids) : undefined
}
/**
* Specialized request handler for XenOrchestra backup operations.
*
* Provides comprehensive backup job management with REST API for read operations
* and WebSocket for creation/execution operations. Handles job lifecycle,
* execution monitoring, and log retrieval.
* Backup jobs (VM, mirror and metadata) and their schedules are created, updated, deleted
* and run through the REST API. Backup archive operations that are not exposed by the
* REST API yet still use the WebSocket API.
*
* @class BackupRequest
* @extends AbstractRequest
*/
export class BackupRequest extends AbstractRequest {
/**
* REST API endpoint for backup jobs
* Note: This is primarily used for read operations
* REST API endpoint for backup jobs (all types: backup, mirrorBackup, metadataBackup)
* @type {string}
*/
endpoint = 'backup-jobs'
// ===========================================================================
// Backup jobs and schedules (REST API)
// ===========================================================================
/**
* Creates a new backup job in XenOrchestra.
* Creates a backup job of any type with `POST /rest/v0/backup-jobs`.
*
* Backup job creation is only available via WebSocket API.
* REST API doesn't support backup job creation operations.
* `config.schedules` are keyed by temporary IDs, which can be used as keys in
* `config.settings`: XO replaces them by the IDs of the created schedules.
*
* @param {Object} config - Complete backup job configuration
* @param {'backup'|'mirrorBackup'|'metadataBackup'} type - Backup job type
* @param {Object} config - Job properties, in the XO format
* @returns {Promise<string>} The created backup job ID
* @throws {Error} If backup job creation fails or configuration is invalid
*/
async createBackupJob(config) {
async #createJob(type, config) {
this._ensureConnected()
if (!config || typeof config !== 'object') {
throw new Error('Valid backup configuration object is required')
}
const convertConfig = cfg => {
const { vms, remotes, srs, ...rest } = cfg
const result = { ...rest }
const vmIds = vms && typeof vms === 'object' ? Object.keys(vms) : []
if (vmIds.length > 0) {
result.vms = toSimplePattern(vmIds)
}
const backupRepositoryIds = remotes && typeof remotes === 'object' ? Object.keys(remotes) : []
if (backupRepositoryIds.length > 0) {
result.remotes = toSimplePattern(backupRepositoryIds)
}
const srIds = srs && typeof srs === 'object' ? Object.keys(srs) : []
if (srIds.length > 0) {
result.srs = toSimplePattern(srIds)
}
return result
}
const xoConfig = convertConfig({ ...config, mode: config.mode || 'delta' })
try {
const result = await this.dispatchClient.xoClient.call('backupNg.createJob', xoConfig)
return result
const { id } = await this.dispatchClient.restApiClient.post(`/rest/v0/${this.endpoint}`, { ...config, type })
return id
} catch (error) {
log.warn('Backup job creation failed', { message: error.message, code: error.code, data: error.data })
log.warn('Backup job creation failed', { type, message: error.message, body: error.body })
throw error
}
}
/**
* Deletes an existing backup job.
* Creates a new VM backup job (backup or replication).
*
* Backup job deletion is only available via WebSocket API.
* `vms`, `remotes` and `srs` are maps keyed by IDs, converted to the `{ id }` pattern expected by XO.
*
* @param {Object} config - Complete backup job configuration
* @returns {Promise<string>} The created backup job ID
*/
async createBackupJob(config) {
const { vms, remotes, srs, ...rest } = config ?? {}
return this.#createJob('backup', {
...rest,
mode: rest.mode || 'delta',
vms: toIdPattern(vms),
remotes: toIdPattern(remotes),
srs: toIdPattern(srs),
})
}
/**
* Creates a new mirror backup job, replicating backups from one remote to others.
*
* @param {Object} config - Mirror backup job configuration
* @param {string} config.mode - 'full' or 'delta'
* @param {string} config.sourceRemote - ID of the source remote
* @param {Object} config.remotes - Destination remotes (e.g., { [remoteId]: {} })
* @param {string} [config.name] - Job name
* @param {Object} [config.schedules] - Schedule definitions
* @param {Object} [config.settings] - Job settings
* @returns {Promise<string>} The created mirror backup job ID
*/
async createMirrorBackupJob(config) {
const { remotes, ...rest } = config ?? {}
return this.#createJob('mirrorBackup', {
...rest,
mode: rest.mode || 'full',
remotes: toIdPattern(remotes),
})
}
/**
* Creates a new metadata backup job.
*
* Accepts a caller-friendly shape:
* { name, pools: { [poolId]: true }, remotes: { [remoteId]: true },
* schedules, settings, xoMetadata }
*
* @param {Object} config - Metadata backup job configuration
* @returns {Promise<string>} The created job ID
*/
async createMetadataBackupJob(config) {
const { pools, remotes, ...rest } = config ?? {}
return this.#createJob('metadataBackup', {
...rest,
pools: toIdPattern(pools),
remotes: toIdPattern(remotes),
})
}
/**
* Updates a backup job of any type with `PATCH /rest/v0/backup-jobs/:id`.
*
* Root properties are replaced (`settings` must be passed entirely), a `null` value removes the property.
*
* @param {string} jobId - ID of the backup job to update
* @param {Object} changes - Properties to update, in the XO format
* @returns {Promise<void>}
*/
async updateBackupJob(jobId, changes) {
this._ensureConnected()
if (!jobId || typeof jobId !== 'string') {
throw new Error('Valid backup job ID is required')
}
try {
await this.dispatchClient.restApiClient.patch(`/rest/v0/${this.endpoint}/${jobId}`, changes)
} catch (error) {
log.warn('Backup job update failed', { jobId, message: error.message, body: error.body })
throw error
}
}
/**
* Deletes a backup job of any type, and its schedules, with `DELETE /rest/v0/backup-jobs/:id`.
*
* @param {string} jobId - ID of the backup job to delete
* @returns {Promise<Object>} Deletion result
* @throws {Error} If deletion fails or job ID is invalid
* @returns {Promise<void>}
* @throws {Error} If deletion fails, `error.status` is 404 if the job does not exist
*/
async deleteBackupJob(jobId) {
this._ensureConnected()
@@ -89,19 +158,127 @@ export class BackupRequest extends AbstractRequest {
throw new Error('Valid backup job ID is required')
}
// Backup job deletion is only available via WebSocket API
try {
const result = await this.dispatchClient.xoClient.call('backupNg.deleteJob', { id: jobId })
return result
await this.dispatchClient.restApiClient.delete(`/rest/v0/${this.endpoint}/${jobId}`)
} catch (error) {
// "no such job" means the caller used the wrong delete method (e.g. mirror job) — let it handle the fallback
if (error?.data?.type !== 'job') {
log.warn('Delete backup job failed', { error })
// a missing job is handled by the caller (e.g. cleanup of an already deleted job)
if (error.status !== 404) {
log.warn('Delete backup job failed', { jobId, message: error.message, body: error.body })
}
throw error
}
}
/**
* Lists all schedules (of backup jobs and other jobs).
*
* @returns {Promise<Array<Object>>}
*/
async listSchedules() {
this._ensureConnected()
return this.dispatchClient.restApiClient.getObjects('/rest/v0/schedules')
}
/**
* Gets a schedule by ID.
*
* @param {string} scheduleId
* @returns {Promise<Object>}
*/
async getSchedule(scheduleId) {
this._ensureConnected()
return this.dispatchClient.restApiClient.get(`/rest/v0/schedules/${scheduleId}`)
}
/**
* Updates a schedule with `PATCH /rest/v0/schedules/:id`.
*
* @param {string} scheduleId
* @param {{cron?: string, enabled?: boolean, name?: string, timezone?: string}} changes
* @returns {Promise<void>}
*/
async updateSchedule(scheduleId, changes) {
this._ensureConnected()
await this.dispatchClient.restApiClient.patch(`/rest/v0/schedules/${scheduleId}`, changes)
}
/**
* Deletes a schedule with `DELETE /rest/v0/schedules/:id`, its settings are removed from its job.
*
* @param {string} scheduleId
* @returns {Promise<void>}
*/
async deleteSchedule(scheduleId) {
this._ensureConnected()
await this.dispatchClient.restApiClient.delete(`/rest/v0/schedules/${scheduleId}`)
}
/**
* Runs a backup job of any type through one of its schedules, and waits for its log.
*
* The run is started with `POST /rest/v0/schedules/:id/actions/run`, which returns
* immediately with the ID of the REST API task running the job.
*
* @param {string} jobId - Backup job ID, used to find the backup log
* @param {string} scheduleId - Schedule ID to use for this execution
* @param {Object} [options]
* @param {number} [options.runStartTime] - Only logs started after it are considered,
* defaults to 5 s before now (buffer for clock differences): pass `Date.now()` when runs follow each other closely
* @returns {Promise<Object>} Complete backup log with status and details
* @throws {Error} If the job fails to start or times out
*/
async runJobAndGetLog(jobId, scheduleId, { runStartTime = Date.now() - 5000 } = {}) {
this._ensureConnected()
log.debug('Running backup job', { jobId, scheduleId })
const restApiClient = this.dispatchClient.restApiClient
let taskId
try {
;({ taskId } = await restApiClient.post(`/rest/v0/schedules/${scheduleId}/actions/run`))
} catch (error) {
log.warn('Run backup job failed', { jobId, scheduleId, message: error.message, body: error.body })
throw error
}
// Wait for the backup to complete and get the log
const result = await waitUntil(
async () => {
const latestLog = await this.dispatchClient.backupLog.getLatestForJob(jobId, runStartTime)
if (latestLog !== undefined) {
// Check if the backup is complete (case-insensitive status check)
const status = latestLog.status?.toLowerCase()
return status === 'success' || status === 'failure' || latestLog.end ? { backupLog: latestLog } : false
}
// No log yet: the job may have failed before starting (e.g. feature not authorized)
const task = await restApiClient.get(`/rest/v0/tasks/${taskId}`)
return task.status === 'failure' ? { failedTask: task } : false
},
2000, // Check every 2 seconds
300_000, // 5 minute timeout
{ exponentialBackoff: true, backoffMultiplier: 1.2, maxInterval: 10_000 }
)
if (result.failedTask !== undefined) {
const reason = result.failedTask.result?.message ?? JSON.stringify(result.failedTask.result)
throw new Error(`Backup job ${jobId} failed to start: ${reason}`)
}
log.debug('Backup completed', { status: result.backupLog.status })
return result.backupLog
}
// ===========================================================================
// Backup archives
// ===========================================================================
/**
* Lists VM backups for specified backup repositories.
*
@@ -238,273 +415,9 @@ export class BackupRequest extends AbstractRequest {
}
// ===========================================================================
// Mirror Backup operations (mirrorBackup.* API)
// Metadata backups
// ===========================================================================
/**
* Creates a new mirror backup job in XenOrchestra.
*
* Mirror backup replicates backups from one remote to another.
* Uses `mirrorBackup.createJob` WebSocket API.
*
* @param {Object} config - Mirror backup job configuration
* @param {string} config.mode - 'full' or 'delta'
* @param {string} config.sourceRemote - ID of the source remote
* @param {Object} config.remotes - Destination remotes (e.g., { [remoteId]: {} })
* @param {string} [config.name] - Job name
* @param {Object} [config.schedules] - Schedule definitions
* @param {Object} [config.settings] - Job settings
* @param {Object} [config.filter] - VM filter (by UUID)
* @returns {Promise<string>} The created mirror backup job ID
*/
async createMirrorBackupJob(config) {
this._ensureConnected()
if (!config || typeof config !== 'object') {
throw new Error('Valid mirror backup configuration object is required')
}
const { remotes, ...rest } = config
// Convert destination remotes to a simple pattern (supports one or several)
const remoteIds = remotes && typeof remotes === 'object' ? Object.keys(remotes) : []
const xoConfig = {
...rest,
mode: config.mode || 'full',
}
if (remoteIds.length > 0) {
xoConfig.remotes = toSimplePattern(remoteIds)
}
try {
const result = await this.dispatchClient.xoClient.call('mirrorBackup.createJob', xoConfig)
return result
} catch (error) {
log.warn('Mirror backup job creation failed', { message: error.message, code: error.code, data: error.data })
throw error
}
}
/**
* Deletes a mirror backup job.
*
* @param {string} jobId - ID of the mirror backup job to delete
* @returns {Promise<Object>} Deletion result
*/
async deleteMirrorBackupJob(jobId) {
this._ensureConnected()
if (!jobId || typeof jobId !== 'string') {
throw new Error('Valid mirror backup job ID is required')
}
try {
const result = await this.dispatchClient.xoClient.call('mirrorBackup.deleteJob', { id: jobId })
return result
} catch (error) {
if (error?.data?.type !== 'job') {
log.warn('Delete mirror backup job failed', { error })
}
throw error
}
}
/**
* Executes a mirror backup job and monitors completion.
*
* @param {string} jobId - Mirror backup job ID to execute
* @param {string} scheduleId - Schedule ID to use for this execution
* @returns {Promise<Object>} Complete backup log with status and details
*/
async runMirrorJobAndGetLog(jobId, scheduleId) {
this._ensureConnected()
log.debug('Running mirror backup job', { jobId, scheduleId })
const runStartTime = Date.now() - 5000
try {
await this.dispatchClient.xoClient.call('mirrorBackup.runJob', {
id: jobId,
schedule: scheduleId,
})
} catch (wsError) {
log.warn('Run mirror backup job failed', { error: wsError.message })
throw wsError
}
const backupLog = await waitUntil(
async () => {
try {
const latestLog = await this.dispatchClient.backupLog.getLatestForJob(jobId, runStartTime)
if (!latestLog) {
return false
}
const status = latestLog.status?.toLowerCase()
if (status === 'success' || status === 'failure' || latestLog.end) {
return latestLog
}
return false
} catch (error) {
log.debug('Waiting for mirror backup completion', { error })
return false
}
},
2000,
300_000,
{ exponentialBackoff: true, backoffMultiplier: 1.2, maxInterval: 10_000 }
)
log.debug('Mirror backup completed', { status: backupLog.status })
return backupLog
}
// ===========================================================================
// Metadata Backup operations (metadataBackup.* API)
// ===========================================================================
/**
* Creates a new metadata backup job in XenOrchestra.
*
* Accepts a caller-friendly shape:
* { name, pools: { [poolId]: true }, remotes: { [remoteId]: true },
* schedules, settings, xoMetadata }
* and converts pools/remotes to the { id } pattern expected by the API.
*
* @param {Object} config - Metadata backup job configuration
* @returns {Promise<string>} The created job ID
*/
async createMetadataBackupJob(config) {
this._ensureConnected()
if (!config || typeof config !== 'object') {
throw new Error('Valid metadata backup configuration object is required')
}
const { pools, remotes, ...rest } = config
const poolIds = pools && typeof pools === 'object' ? Object.keys(pools) : []
const remoteIds = remotes && typeof remotes === 'object' ? Object.keys(remotes) : []
const xoConfig = { ...rest }
if (poolIds.length > 0) xoConfig.pools = toSimplePattern(poolIds)
if (remoteIds.length > 0) xoConfig.remotes = toSimplePattern(remoteIds)
try {
const result = await this.dispatchClient.xoClient.call('metadataBackup.createJob', xoConfig)
// metadataBackup.createJob returns the full job object (unlike backupNg.createJob which returns only the ID)
return result?.id ?? result
} catch (error) {
log.warn('Metadata backup job creation failed', { message: error.message, code: error.code, data: error.data })
throw error
}
}
/**
* Gets a metadata backup job by ID.
*
* Uses the WebSocket API because the backup-jobs REST endpoint only covers VM backup jobs.
*
* @param {string} jobId - Metadata backup job ID
* @returns {Promise<Object>} The job object
*/
async getMetadataJob(jobId) {
this._ensureConnected()
if (!jobId || typeof jobId !== 'string') {
throw new Error('Valid job ID is required')
}
try {
return await this.dispatchClient.xoClient.call('metadataBackup.getJob', { id: jobId })
} catch (error) {
log.warn('Get metadata backup job failed', { jobId, error })
throw error
}
}
/**
* Deletes a metadata backup job.
*
* @param {string} jobId - Metadata backup job ID to delete
* @returns {Promise<void>}
*/
async deleteMetadataBackupJob(jobId) {
this._ensureConnected()
if (!jobId || typeof jobId !== 'string') {
throw new Error('Valid job ID is required')
}
try {
return await this.dispatchClient.xoClient.call('metadataBackup.deleteJob', { id: jobId })
} catch (error) {
log.warn('Delete metadata backup job failed', { jobId, error })
throw error
}
}
/**
* Executes a metadata backup job and monitors completion.
*
* Same polling pattern as runJobAndGetLog, but calls metadataBackup.runJob.
*
* @param {string} jobId - Metadata backup job ID to execute
* @param {string} scheduleId - Schedule ID to use for this execution
* @returns {Promise<Object>} Complete backup log with status and details
*/
async runMetadataJobAndGetLog(jobId, scheduleId) {
this._ensureConnected()
log.debug('Running metadata backup job', { jobId, scheduleId })
const runStartTime = Date.now()
try {
await this.dispatchClient.xoClient.call('metadataBackup.runJob', {
id: jobId,
schedule: scheduleId,
})
} catch (wsError) {
// The server has a known bug where a post-job cleanup error of `undefined` causes
// serializeError() to throw, returning a -32000 WS error even though the backup
// already completed. Continue polling for the log rather than failing immediately.
log.debug('metadataBackup.runJob returned a WS error, polling for backup log anyway', {
jobId,
code: wsError.code,
error: wsError.message,
})
}
const backupLog = await waitUntil(
async () => {
try {
const latestLog = await this.dispatchClient.backupLog.getLatestForJob(jobId, runStartTime)
if (!latestLog) return false
const status = latestLog.status?.toLowerCase()
if (status === 'success' || status === 'failure' || latestLog.end) {
return latestLog
}
return false
} catch (error) {
log.debug('Waiting for metadata backup completion', { error })
return false
}
},
2000,
300_000,
{ exponentialBackoff: true, backoffMultiplier: 1.2, maxInterval: 10_000 }
)
log.debug('Metadata backup completed', { status: backupLog.status })
return backupLog
}
/**
* Lists metadata backups for the given backup repositories.
*
@@ -525,74 +438,4 @@ export class BackupRequest extends AbstractRequest {
throw error
}
}
// ===========================================================================
// Standard Backup operations (backupNg.* API)
// ===========================================================================
/**
* Executes a backup job and monitors completion.
*
* Starts the backup job and waits for completion with detailed logging.
* Combines job execution and log monitoring in a single method.
*
* @param {string} jobId - Backup job ID to execute
* @param {string} scheduleId - Schedule ID to use for this execution
* @returns {Promise<Object>} Complete backup log with status and details
* @throws {Error} If job execution fails or times out
*/
async runJobAndGetLog(jobId, scheduleId) {
this._ensureConnected()
log.debug('Running backup job', { jobId, scheduleId })
// Capture timestamp BEFORE starting the job (with small buffer for clock differences)
const runStartTime = Date.now() - 5000 // 5 second buffer before job start
// Backup job execution is only available via WebSocket API
try {
await this.dispatchClient.xoClient.call('backupNg.runJob', {
id: jobId,
schedule: scheduleId,
})
} catch (wsError) {
if (wsError.code === -32000 && wsError.message.includes('task has not started yet')) {
// sometimes we get a task not started error
await new Promise(resolve => setTimeout(resolve, 10_000))
} else {
console.error('❌ Run backup job failed:', wsError.message)
throw wsError
}
}
// Wait for the backup to complete and get the log
const backupLog = await waitUntil(
async () => {
try {
const latestLog = await this.dispatchClient.backupLog.getLatestForJob(jobId, runStartTime)
if (!latestLog) {
return false // Keep waiting
}
// Check if the backup is complete (case-insensitive status check)
const status = latestLog.status?.toLowerCase()
if (status === 'success' || status === 'failure' || latestLog.end) {
return latestLog
}
return false // Still running
} catch (error) {
log.debug('Waiting for backup completion', { error })
return false
}
},
2000, // Check every 2 seconds
300_000, // 5 minute timeout
{ exponentialBackoff: true, backoffMultiplier: 1.2, maxInterval: 10_000 }
)
log.debug('Backup completed', { status: backupLog.status })
return backupLog
}
}

View File

@@ -136,6 +136,21 @@ export class RestApiClient {
return fetchFn()
}
/**
* Builds the error of a failed response, keeping its status and body so callers
* can react to a specific status (e.g. 404) and log the reason sent by XO.
*
* @param {Response} response - Non-ok fetch response
* @returns {Promise<Error & {status: number, body: string}>}
* @private
*/
static async #httpError(response) {
const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
error.status = response.status
error.body = await response.text().catch(() => '')
return error
}
/**
* Basic GET request method.
*
@@ -157,7 +172,7 @@ export class RestApiClient {
const text = await response.text()
return text ? JSON.parse(text) : {}
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
throw await RestApiClient.#httpError(response)
} catch (error) {
log.warn('GET request failed on ' + endpoint, { error })
throw error
@@ -190,7 +205,7 @@ export class RestApiClient {
const text = await response.text()
return text ? JSON.parse(text) : {}
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
throw await RestApiClient.#httpError(response)
} catch (error) {
log.warn(`${method} request failed`, { error })
throw error
@@ -255,7 +270,7 @@ export class RestApiClient {
const text = await response.text()
return text ? JSON.parse(text) : {}
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
throw await RestApiClient.#httpError(response)
} catch (error) {
log.warn('DELETE request failed', { error })
throw error

View File

@@ -134,7 +134,7 @@ describe('Mirror Backup - Full Remote', () => {
assert.ok(scheduleKey, 'Mirror schedule key is required')
tracker.trackResource('schedule', scheduleKey, { name, backupJobId: jobId })
const result = await dispatchClient.backup.runMirrorJobAndGetLog(jobId, scheduleKey)
const result = await dispatchClient.backup.runJobAndGetLog(jobId, scheduleKey)
assertBackupSuccess(result, `Mirror backup: ${name}`)
return result
}
@@ -258,6 +258,60 @@ describe('Mirror Backup - Full Remote', () => {
})
})
// =========================================================================
// Long-term retention
// =========================================================================
describe('Long-term retention', () => {
after(async () => {
await purgeBackupData(sourceRepository, destRepository)
})
it('should create and run a mirror job with long-term retention', async () => {
const longTermRetention = {
daily: { retention: 3, settings: {} },
weekly: { retention: 2, settings: {} },
}
log.debug('Running full backup to populate source repository')
await runFullBackup()
// a named temporary schedule ID keeps the general settings ('') apart from the schedule ones
const name = generateBackupJobName()
const jobId = await dispatchClient.backup.createMirrorBackupJob({
name,
mode: 'full',
sourceRemote: sourceRepository.id,
remotes: { [destRepository.id]: {} },
schedules: { tmpSchedule: getDefaultSchedule() },
settings: {
// the timezone is required by the long-term retention
'': { timezone: 'Europe/Paris', longTermRetention },
tmpSchedule: { exportRetention: 1 },
},
})
tracker.trackResource('backupJob', jobId, { name, mode: 'full', type: 'mirrorBackup' })
const job = await dispatchClient.backup.details(jobId)
assert.strictEqual(job.type, 'mirrorBackup')
assert.deepStrictEqual(job.settings[''].longTermRetention, longTermRetention, 'LTR should be kept on the job')
assert.strictEqual(job.settings[''].timezone, 'Europe/Paris', 'Timezone should be kept on the job')
const scheduleKey = getScheduleKey(job)
assert.ok(scheduleKey, 'Mirror schedule key is required')
assert.notStrictEqual(scheduleKey, 'tmpSchedule', 'Temporary schedule ID should be replaced')
assert.strictEqual(job.settings[scheduleKey].exportRetention, 1, 'Schedule settings should be kept')
tracker.trackResource('schedule', scheduleKey, { name, backupJobId: jobId })
// the LTR is applied when the destination is cleaned, a misconfiguration fails the run
const result = await dispatchClient.backup.runJobAndGetLog(jobId, scheduleKey)
assertBackupSuccess(result, `Mirror backup with LTR: ${name}`)
const destCount = await countVmBackups(destRepository.id, vm.uuid)
assert.ok(destCount > 0, 'Destination repository should contain backups after mirror')
})
})
// =========================================================================
// Retention — source retention > destination retention
// =========================================================================

View File

@@ -117,6 +117,36 @@ describe('Backup basic tests', () => {
assert(backupJob.name === name)
})
})
describe('Edit and delete backup job', () => {
it('should update the backup job and its schedule', async () => {
await createBackupJobForTest(defaultConfig, 'delta')
const scheduleId = getScheduleKey(backupJob)
assert(scheduleId, 'Schedule key is required but was undefined')
const newName = `${name} (edited)`
await dispatchClient.backup.updateBackupJob(backupJobId, { name: newName, mode: 'full' })
const updatedJob = await dispatchClient.backup.details(backupJobId)
assert.strictEqual(updatedJob.name, newName, 'Job name should be updated')
assert.strictEqual(updatedJob.mode, 'full', 'Job mode should be updated')
assert.deepStrictEqual(updatedJob.vms, backupJob.vms, 'Properties not passed should be kept')
await dispatchClient.backup.updateSchedule(scheduleId, { cron: '0 12 * * *', enabled: false })
const schedule = await dispatchClient.backup.getSchedule(scheduleId)
assert.strictEqual(schedule.cron, '0 12 * * *', 'Schedule cron should be updated')
assert.strictEqual(schedule.enabled, false, 'Schedule should be disabled')
assert.strictEqual(schedule.jobId, backupJobId, 'Schedule should still belong to the job')
})
it('should delete the backup job and its schedule', async () => {
await createBackupJobForTest(defaultConfig, 'delta')
const scheduleId = getScheduleKey(backupJob)
assert(scheduleId, 'Schedule key is required but was undefined')
await dispatchClient.backup.deleteBackupJob(backupJobId)
await assert.rejects(dispatchClient.backup.details(backupJobId), { status: 404 }, 'Job should be deleted')
await assert.rejects(dispatchClient.backup.getSchedule(scheduleId), { status: 404 }, 'Schedule should be deleted')
})
})
describe('Run backup jobs', () => {
it('should run the backup job in delta mode with health checks', async () => {
await createBackupJobForTest(defaultConfig, 'delta')

View File

@@ -33,7 +33,7 @@ function findMetadataTask(tasks, type) {
/**
* Asserts that a metadata task of the given type exists and succeeded.
*
* @param {Object} logEntry - Backup log returned by runMetadataJobAndGetLog
* @param {Object} logEntry - Backup log returned by runJobAndGetLog
* @param {'pool'|'xo'} type - Metadata task type to assert on
*/
function assertMetadataTaskSuccess(logEntry, type) {
@@ -112,19 +112,15 @@ describe('Metadata backup tests', () => {
assert.ok(jobId, 'createMetadataBackupJob should return a job ID')
const job = await dispatchClient.backup.getMetadataJob(jobId)
const job = await dispatchClient.backup.details(jobId)
assert.strictEqual(job.name, name, 'Job name should match')
assert.ok(job.pools, 'Job should have pools configured')
assert.strictEqual(job.xoMetadata, true, 'Job should have xoMetadata enabled')
log.debug('Metadata backup job created and verified', { jobId, name })
// Cleanup: this test only validates creation, delete the job immediately
await dispatchClient.backup.deleteMetadataBackupJob(jobId)
const scheduleKey = getScheduleKey(job)
if (scheduleKey) {
await dispatchClient.xoClient.call('schedule.delete', { id: scheduleKey })
}
// Cleanup: this test only validates creation, delete the job (and its schedule) immediately
await dispatchClient.backup.deleteBackupJob(jobId)
log.debug('Metadata backup job cleaned up', { jobId })
})
})
@@ -150,7 +146,7 @@ describe('Metadata backup tests', () => {
xoMetadata: true,
})
const job = await dispatchClient.backup.getMetadataJob(jobId)
const job = await dispatchClient.backup.details(jobId)
scheduleKey = getScheduleKey(job)
assert.ok(scheduleKey, 'Metadata backup job should have a schedule key after creation')
@@ -160,21 +156,13 @@ describe('Metadata backup tests', () => {
after(async () => {
if (jobId) {
try {
await dispatchClient.backup.deleteMetadataBackupJob(jobId)
// also deletes its schedule
await dispatchClient.backup.deleteBackupJob(jobId)
log.debug('Deleted metadata backup job', { jobId })
} catch (error) {
log.warn('Failed to delete metadata backup job', { jobId, error })
}
}
if (scheduleKey) {
try {
await dispatchClient.xoClient.call('schedule.delete', { id: scheduleKey })
log.debug('Deleted schedule', { scheduleKey })
} catch (error) {
log.warn('Failed to delete schedule', { scheduleKey, error })
}
}
})
it('each run should succeed with pool metadata and XO config tasks', async () => {
@@ -187,7 +175,8 @@ describe('Metadata backup tests', () => {
log.debug(`Running metadata backup job (${runIndex}/3)`, { jobId, scheduleKey })
const result = await dispatchClient.backup.runMetadataJobAndGetLog(jobId, scheduleKey)
// runs are 2 s apart: the default 5 s look-back would return the log of the previous run
const result = await dispatchClient.backup.runJobAndGetLog(jobId, scheduleKey, { runStartTime: Date.now() })
assertBackupSuccess(result, `Metadata backup run ${runIndex}/3`)
assertMetadataTaskSuccess(result, 'pool')

View File

@@ -41,6 +41,7 @@
"@vates/types": "^2.5.0",
"@xen-orchestra/acl": "^1.8.0",
"@xen-orchestra/backups": "^0.78.0",
"@xen-orchestra/cron": "^1.0.7",
"@xen-orchestra/log": "^0.7.2",
"@xen-orchestra/qcow2": "^1.4.1",
"@xen-orchestra/vmdk": "^1.0.0",

View File

@@ -10,21 +10,26 @@ import { createLogger } from '@xen-orchestra/log'
import { inject } from 'inversify'
import { noSuchObject } from 'xo-common/api-errors.js'
import {
Body,
Delete,
Deprecated,
Example,
Extension,
Get,
Hidden,
Middlewares,
Patch,
Path,
Post,
Query,
Request,
Response,
Route,
Security,
SuccessResponse,
Tags,
} from 'tsoa'
import type { Request as ExRequest, Response as ExResponse } from 'express'
import { json, type Request as ExRequest, type Response as ExResponse } from 'express'
import { provide } from 'inversify-binding-decorators'
import { acl, autoBindService } from '../middlewares/acl.middleware.mjs'
@@ -32,7 +37,10 @@ import { backupLog, backupLogIds, partialBackupLogs } from '../open-api/oa-examp
import { BackupLogService } from '../backup-logs/backup-log.service.mjs'
import {
badRequestResp,
createdResp,
forbiddenOperationResp,
invalidParameters,
noContentResp,
notFoundResp,
unauthorizedResp,
Unbrand,
@@ -40,7 +48,9 @@ import {
import { RestApi } from '../rest-api/rest-api.mjs'
import { BASE_URL, limitAndFilterArray, safeParseComplexMatcher } from '../helpers/utils.helper.mjs'
import type {
CreateBackupJobBody,
UnbrandAnyXoBackupJob,
UpdateBackupJobBody,
UnbrandXoMetadataBackupJob,
UnbrandXoMirrorBackupJob,
UnbrandXoVmBackupJob,
@@ -48,6 +58,7 @@ import type {
import { SendObjects } from '../helpers/helper.type.mjs'
import { XoController } from '../abstract-classes/xo-controller.mjs'
import {
backupJobId,
metadataBackupJob,
metadataBackupJobIds,
mirrorBackupJob,
@@ -138,6 +149,79 @@ export class BackupJobController extends XoController<AnyXoBackupJob> {
getBackupJob(@Path() id: string): Promise<UnbrandAnyXoBackupJob> {
return this.getObject(id as AnyXoBackupJob['id'])
}
/**
* Create a backup job, and optionally its schedules.
*
* `schedules` are keyed by temporary IDs that can be used as keys in `settings`,
* they are replaced by the IDs of the created schedules.
*
* Only usable by administrators.
*
* @example body {
* "type": "backup",
* "name": "Daily delta backup",
* "mode": "delta",
* "vms": { "id": "613f541c-4bed-fc77-7ca8-2db6b68f079c" },
* "remotes": { "id": "b2b8a8d8-a7cd-4ac2-8a1b-a3e9ec9a7fe1" },
* "settings": {
* "": { "reportWhen": "failure" },
* "tmp-schedule": { "exportRetention": 7 }
* },
* "schedules": {
* "tmp-schedule": { "cron": "0 0 * * *", "enabled": true, "name": "daily", "timezone": "Europe/Paris" }
* }
* }
*/
@Example(backupJobId)
@Extension('x-mcp-exposure', 'confirm')
@Post('')
@Middlewares(json())
@SuccessResponse(createdResp.status, createdResp.description)
@Response(invalidParameters.status, invalidParameters.description)
async createBackupJob(@Body() body: CreateBackupJobBody): Promise<{ id: Unbrand<AnyXoBackupJob>['id'] }> {
const job = await this.#backupJobService.createBackupJob(body)
return { id: job.id }
}
/**
* Update a backup job.
*
* Root properties are replaced (`settings` must be passed entirely).
* The type of a job cannot be changed.
*
* Only usable by administrators.
*
* @example id "d33f3dc1-92b4-469c-ad58-4c2a106a4721"
* @example body { "name": "new backup job name" }
*/
@Extension('x-mcp-exposure', 'confirm')
@Patch('{id}')
@Middlewares(json())
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(invalidParameters.status, invalidParameters.description)
async updateBackupJob(@Path() id: string, @Body() body: UpdateBackupJobBody): Promise<void> {
const job = await this.getObject(id as AnyXoBackupJob['id'])
this.#backupJobService.assertJobHasValidExpressions(job.type, body)
await this.restApi.xoApp.updateJob({ ...body, id: job.id })
}
/**
* Delete a backup job and its schedules.
*
* Only usable by administrators.
*
* @example id "d33f3dc1-92b4-469c-ad58-4c2a106a4721"
*/
@Extension('x-mcp-exposure', 'confirm')
@Delete('{id}')
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async deleteBackupJob(@Path() id: string): Promise<void> {
const job = await this.getObject(id as AnyXoBackupJob['id'])
await this.restApi.xoApp.removeJob(job.id)
}
}
// ----------- DEPRECATED TO BE REMOVED IN ONE YEAR (09-12-2026)--------------------

View File

@@ -1,10 +1,20 @@
import { createPredicate } from 'value-matcher'
import { assertValidExpression, assertValidSettingsExpression } from '@xen-orchestra/backups/backupExpressions.mjs'
import { extractIdsFromSimplePattern } from '@xen-orchestra/backups/extractIdsFromSimplePattern.mjs'
import { noSuchObject } from 'xo-common/api-errors.js'
import type { XoVmBackupJob, XoVm, XoSchedule, AnyXoJob, AnyXoBackupJob } from '@vates/types'
import { invalidParameters, noSuchObject } from 'xo-common/api-errors.js'
import type {
XoVmBackupJob,
XoVm,
XoSchedule,
AnyXoJob,
AnyXoBackupJob,
XoMetadataBackupJob,
XoMirrorBackupJob,
} from '@vates/types'
import type { RestApi } from '../rest-api/rest-api.mjs'
import { vmContainsNoBakTag } from '../helpers/utils.helper.mjs'
import { assertValidCron, vmContainsNoBakTag } from '../helpers/utils.helper.mjs'
import type { CreateBackupJobBody, CreateBackupJobSchedule, UpdateBackupJobBody } from './backup-job.type.mjs'
export class BackupJobService {
#backupJobTypes = ['backup', 'metadataBackup', 'mirrorBackup']
@@ -23,6 +33,51 @@ export class BackupJobService {
return job
}
async createBackupJob({ schedules: _schedules = {}, ...props }: CreateBackupJobBody): Promise<AnyXoBackupJob> {
const xoApp = this.#restApi.xoApp
this.assertJobHasValidExpressions(props.type, props)
const schedules: Record<string, Pick<XoSchedule, 'cron' | 'enabled' | 'name' | 'timezone'>> = {}
for (const [tmpId, { enabled = false, ...schedule }] of Object.entries<CreateBackupJobSchedule>(_schedules)) {
// `''` is the key of the general settings, xo-server would move them to the schedule
if (tmpId === '') {
throw invalidParameters('schedule temporary ID cannot be an empty string')
}
assertValidCron(schedule.cron, schedule.timezone)
schedules[tmpId] = { ...schedule, enabled }
}
if (props.type === 'metadataBackup') {
const userId = this.#restApi.getCurrentUser().id
return xoApp.createMetadataBackupJob(
{ ...props, userId } as unknown as Omit<XoMetadataBackupJob, 'id' | 'type'>,
schedules
)
}
const { type, ...job } = props
return xoApp.createBackupNgJob<XoVmBackupJob | XoMirrorBackupJob>(
type,
job as unknown as Omit<XoVmBackupJob | XoMirrorBackupJob, 'id' | 'type'>,
schedules
)
}
/**
* Same checks as the JSON-RPC API (`backupNg.*Job`, `mirrorBackup.*Job`),
* so an invalid expression is refused with a 400 instead of failing every run
*/
assertJobHasValidExpressions(type: AnyXoBackupJob['type'], job: UpdateBackupJobBody): void {
if (type === 'backup') {
const { srs, remotes, settings } = job as Extract<UpdateBackupJobBody, { srs?: unknown }>
assertValidExpression('srs', srs)
assertValidExpression('remotes', remotes)
assertValidSettingsExpression(settings, ['healthCheckVmsWithTags', 'fullInterval'])
} else if (type === 'mirrorBackup') {
assertValidSettingsExpression(job.settings, ['healthCheckVmsWithTags', 'vmFilter'])
}
}
isBackupJob(anyJob: AnyXoJob): anyJob is AnyXoBackupJob {
return this.#backupJobTypes.includes(anyJob.type)
}

View File

@@ -43,3 +43,29 @@ type UnbrandedXoMirrorBackupJob = Omit<XoMirrorBackupJob, 'settings' | 'remotes'
export type UnbrandXoMirrorBackupJob = Unbrand<UnbrandedXoMirrorBackupJob>
export type UnbrandAnyXoBackupJob = UnbrandXoVmBackupJob | UnbrandXoMetadataBackupJob | UnbrandXoMirrorBackupJob
export type CreateBackupJobSchedule = {
cron: string
enabled?: boolean
name?: string
timezone?: string
}
type WithSchedules = {
/**
* Schedules to create along the job, keyed by temporary IDs.
*
* These temporary IDs can be used as keys in `settings`, they will be replaced by the IDs of the created schedules.
*/
schedules?: Record<string, CreateBackupJobSchedule>
}
export type CreateVmBackupJobBody = Omit<UnbrandXoVmBackupJob, 'id'> & WithSchedules
export type CreateMetadataBackupJobBody = Omit<UnbrandXoMetadataBackupJob, 'id' | 'userId'> & WithSchedules
export type CreateMirrorBackupJobBody = Omit<UnbrandXoMirrorBackupJob, 'id'> & WithSchedules
export type CreateBackupJobBody = CreateVmBackupJobBody | CreateMetadataBackupJobBody | CreateMirrorBackupJobBody
export type UpdateVmBackupJobBody = Partial<Omit<UnbrandXoVmBackupJob, 'id' | 'type'>>
export type UpdateMetadataBackupJobBody = Partial<Omit<UnbrandXoMetadataBackupJob, 'id' | 'type' | 'userId'>>
export type UpdateMirrorBackupJobBody = Partial<Omit<UnbrandXoMirrorBackupJob, 'id' | 'type'>>
export type UpdateBackupJobBody = UpdateVmBackupJobBody | UpdateMetadataBackupJobBody | UpdateMirrorBackupJobBody

View File

@@ -1,6 +1,8 @@
import * as CM from 'complex-matcher'
import { AnyXoVm, XoSr } from '@vates/types'
import { createLogger } from '@xen-orchestra/log'
import { createSchedule } from '@xen-orchestra/cron'
import { invalidParameters } from 'xo-common/api-errors.js'
import { isPromise } from 'node:util/types'
import { MaybePromise, PromiseWriteInStreamError } from './helper.type.mjs'
@@ -18,6 +20,19 @@ export const isSrWritable = (sr: XoSr) => isSrWritableOrIso(sr) && sr.content_ty
export const isSrWritableOrIso = (sr: XoSr) => sr.size > 0
export const isReplicaVm = (vm: AnyXoVm) => 'start' in vm.blockedOperations && vm.other['xo:backup:job'] !== undefined
export const vmContainsNoBakTag = (vm: AnyXoVm) => vm.tags.some(t => t.split('=', 1)[0] === 'xo:no-bak')
/**
* xo-server only parses the cron when starting the schedule, after saving it:
* check it before any write to answer 400 instead of saving a broken schedule
*/
export const assertValidCron = (cron: string, timezone?: string) => {
try {
createSchedule(cron, timezone).next(1)
} catch (error) {
throw invalidParameters(`invalid cron "${cron}": ${(error as Error).message}`)
}
}
export const getTopPerProperty = <T,>(
array: T[],
{ length = Infinity, prop }: { length?: number; prop: keyof T & string }

View File

@@ -134,3 +134,7 @@ export const mirrorBackupJob = {
proxy: '83050a39-44e2-4e59-b612-860250ce9338',
id: 'e680c14c-ab52-45c8-bb0e-bd4ca12ea8f9',
}
export const backupJobId = {
id: 'd33f3dc1-92b4-469c-ad58-4c2a106a4721',
}

View File

@@ -33,3 +33,7 @@ export const schedule = {
export const createNetwork = {
id: '9fe12ca3-d75d-cfb0-492e-cfd2bc6c568f',
}
export const scheduleId = {
id: 'cf7249f8-d20b-494f-97f4-b1f32f94e780',
}

View File

@@ -1,9 +1,12 @@
import type { XoSchedule } from '@vates/types'
import type { AnyXoJob, XoSchedule } from '@vates/types'
import {
Body,
Delete,
Example,
Extension,
Get,
Middlewares,
Patch,
Path,
Post,
Query,
@@ -16,26 +19,31 @@ import {
} from 'tsoa'
import { inject } from 'inversify'
import { provide } from 'inversify-binding-decorators'
import type { Request as ExRequest } from 'express'
import { json, type Request as ExRequest } from 'express'
import { noSuchObject } from 'xo-common/api-errors.js'
import { acl } from '../middlewares/acl.middleware.mjs'
import {
asynchronousActionResp,
badRequestResp,
createdResp,
featureUnauthorized,
forbiddenOperationResp,
internalServerErrorResp,
invalidParameters,
noContentResp,
notFoundResp,
unauthorizedResp,
type Unbrand,
} from '../open-api/common/response.common.mjs'
import { partialSchedules, schedule, scheduleIds } from '../open-api/oa-examples/schedule.oa-example.mjs'
import { partialSchedules, schedule, scheduleId, scheduleIds } from '../open-api/oa-examples/schedule.oa-example.mjs'
import { taskLocation } from '../open-api/oa-examples/task.oa-example.mjs'
import type { SendObjects } from '../helpers/helper.type.mjs'
import { XoController } from '../abstract-classes/xo-controller.mjs'
import { assertValidCron } from '../helpers/utils.helper.mjs'
import type { CreateActionReturnType } from '../abstract-classes/base-controller.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import type { CreateScheduleBody, UpdateScheduleBody } from './schedule.type.mjs'
@Route('schedules')
@Security('*')
@@ -152,4 +160,106 @@ export class ScheduleController extends XoController<XoSchedule> {
taskProperties: { name: 'run schedule', objectId: scheduleId },
})
}
/**
* Create a schedule for an existing job.
*
* Only usable by administrators.
*
* @example body {
* "cron": "0 0 * * *",
* "enabled": true,
* "jobId": "a87827cf-57fc-48a6-aa77-a0a14771f8f3",
* "name": "daily",
* "timezone": "Europe/Paris"
* }
*/
@Example(scheduleId)
@Extension('x-mcp-exposure', 'confirm')
@Post('')
@Middlewares(json())
@SuccessResponse(createdResp.status, createdResp.description)
@Response(invalidParameters.status, invalidParameters.description)
@Response(notFoundResp.status, notFoundResp.description)
async createSchedule(@Body() body: CreateScheduleBody): Promise<{ id: Unbrand<XoSchedule>['id'] }> {
const xoApp = this.restApi.xoApp
const { enabled = false, jobId, ...props } = body
assertValidCron(props.cron, props.timezone)
// throws if the job does not exist
const job = await xoApp.getJob(jobId as AnyXoJob['id'])
const schedule = await xoApp.createSchedule({
...props,
enabled,
jobId: job.id,
userId: this.restApi.getCurrentUser().id,
})
// backup executors expect an entry per schedule in the job settings
if ('settings' in job) {
await xoApp.updateJob({ id: job.id, settings: { ...job.settings, [schedule.id]: {} } })
}
return { id: schedule.id }
}
/**
* Only usable by administrators.
*
* @example id "cf7249f8-d20b-494f-97f4-b1f32f94e780"
* @example body { "enabled": false }
*/
@Extension('x-mcp-exposure', 'confirm')
@Patch('{id}')
@Middlewares(json())
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(invalidParameters.status, invalidParameters.description)
@Response(notFoundResp.status, notFoundResp.description)
async updateSchedule(@Path() id: string, @Body() body: UpdateScheduleBody): Promise<void> {
const schedule = await this.getObject(id as XoSchedule['id'])
// xo-server stops the schedule before parsing the new cron
if (body.cron !== undefined || body.timezone !== undefined) {
assertValidCron(body.cron ?? schedule.cron, body.timezone ?? schedule.timezone)
}
await this.restApi.xoApp.updateSchedule({ ...body, id: schedule.id })
}
/**
* Delete a schedule, and its settings from the related job.
*
* The job settings are read then rewritten: concurrent changes to the settings of the same job may be lost.
*
* Only usable by administrators.
*
* @example id "cf7249f8-d20b-494f-97f4-b1f32f94e780"
*/
@Extension('x-mcp-exposure', 'confirm')
@Delete('{id}')
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async deleteSchedule(@Path() id: string): Promise<void> {
const xoApp = this.restApi.xoApp
const schedule = await this.getObject(id as XoSchedule['id'])
await xoApp.deleteSchedule(schedule.id)
let job: AnyXoJob
try {
job = await xoApp.getJob(schedule.jobId)
} catch (error) {
if (noSuchObject.is(error)) {
return
}
throw error
}
if ('settings' in job && job.settings[schedule.id] !== undefined) {
const { [schedule.id]: _, ...settings } = job.settings
await xoApp.updateJob({ id: job.id, settings })
}
}
}

View File

@@ -0,0 +1,15 @@
export type CreateScheduleBody = {
cron: string
/** Default to `false` */
enabled?: boolean
jobId: string
name?: string
timezone?: string
}
export type UpdateScheduleBody = {
cron?: string
enabled?: boolean
name?: string
timezone?: string
}

View File

@@ -17,6 +17,7 @@
- [Pool/Network] Add network dedicated page, with General and PIFs tabs (PR [#10704](https://github.com/vatesfr/xen-orchestra/pull/10704))
- [Pool/Traffic rules] Display traffic rules by priority within their network (PR [#10688](https://github.com/vatesfr/xen-orchestra/pull/10688))
- [VMs/Hosts] Show a loader in VM and host icons while their power state is changing, with the operation in progress in the tooltip, and move the header loader next to the name (PR [#10720](https://github.com/vatesfr/xen-orchestra/pull/10720))
- [REST API] Create, update and delete backup jobs and schedules (admin only): `POST /rest/v0/backup-jobs`, `PATCH|DELETE /rest/v0/backup-jobs/:id`, `POST /rest/v0/schedules`, `PATCH|DELETE /rest/v0/schedules/:id` (PR [#10595](https://github.com/vatesfr/xen-orchestra/pull/10595))
### Bug fixes
@@ -44,7 +45,7 @@
- @vates/types minor
- @xen-orchestra/backups minor
- @xen-orchestra/qa-test patch
- @xen-orchestra/rest-api patch
- @xen-orchestra/rest-api minor
- @xen-orchestra/web minor
- @xen-orchestra/web-core minor
- xo-server minor

View File

@@ -5,7 +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'
import { assertValidSettingsExpression, assertValidExpression } from '@xen-orchestra/backups/backupExpressions.mjs'
const SCHEMA_SETTINGS = {
type: 'object',

View File

@@ -1,4 +1,4 @@
import { assertValidSettingsExpression } from './_backupExpressions.mjs'
import { assertValidSettingsExpression } from '@xen-orchestra/backups/backupExpressions.mjs'
const SCHEMA_SETTINGS = {
type: 'object',

View File

@@ -181,7 +181,7 @@ export default class Jobs {
promises.push(this._app.deleteSchedule(schedule.id))
}
})
return Promise.all(promises)
await Promise.all(promises)
}
@decorateWith(defer)