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, XoConfigBackupArchive,
XoGroup, XoGroup,
XoHost, XoHost,
XoMetadataBackupJob,
XoMirrorBackupJob,
XoPlugin, XoPlugin,
XoPool, XoPool,
XoPoolBackupArchive, XoPoolBackupArchive,
@@ -21,6 +23,7 @@ import type {
XoVif, XoVif,
XoVm, XoVm,
XoVmBackupArchive, XoVmBackupArchive,
XoVmBackupJob,
} from './xo.mjs' } from './xo.mjs'
import { VatesTask } from './lib/vates-task.mjs' import { VatesTask } from './lib/vates-task.mjs'
import type { PluginRestRouteDefinition } from './lib/rest-api.mjs' import type { PluginRestRouteDefinition } from './lib/rest-api.mjs'
@@ -309,6 +312,17 @@ export type XoApp = EventEmitter & {
set(key: string, value: unknown): void set(key: string, value: unknown): void
unset(key: string): 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: { createAuthenticationToken(opts: {
client?: { client?: {
id?: string id?: string
@@ -324,6 +338,14 @@ export type XoApp = EventEmitter & {
proxy?: XoProxy['id'] proxy?: XoProxy['id']
url: string url: string
}): Promise<XoBackupRepository> }): 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: { createUser(params: {
firstname?: string firstname?: string
lastname?: string lastname?: string
@@ -340,6 +362,7 @@ export type XoApp = EventEmitter & {
deleteAclV2Privilege(privilegeId: XoAclBasePrivilege['id'], options?: { force?: boolean }): Promise<boolean> deleteAclV2Privilege(privilegeId: XoAclBasePrivilege['id'], options?: { force?: boolean }): Promise<boolean>
deleteAclV2Role(roleId: XoAclRole['id'], options?: { force?: boolean }): Promise<boolean> deleteAclV2Role(roleId: XoAclRole['id'], options?: { force?: boolean }): Promise<boolean>
deleteGroup(id: XoGroup['id']): Promise<void> deleteGroup(id: XoGroup['id']): Promise<void>
deleteSchedule(id: XoSchedule['id']): Promise<void>
deleteUser(id: XoUser['id']): Promise<void> deleteUser(id: XoUser['id']): Promise<void>
detachHostFromPool(hostId: XoHost['id']): Promise<void> detachHostFromPool(hostId: XoHost['id']): Promise<void>
createGroup(params: { name: string; provider?: string; providerGroup?: string }): Promise<XoGroup> createGroup(params: { name: string; provider?: string; providerGroup?: string }): Promise<XoGroup>
@@ -378,6 +401,8 @@ export type XoApp = EventEmitter & {
getRemote(id: XoBackupRepository['id']): Promise<XoBackupRepository> getRemote(id: XoBackupRepository['id']): Promise<XoBackupRepository>
getAllRemotes(): Promise<XoBackupRepository[]> getAllRemotes(): Promise<XoBackupRepository[]>
removeRemote(id: XoBackupRepository['id']): Promise<void> removeRemote(id: XoBackupRepository['id']): Promise<void>
/** Also deletes the schedules of the job */
removeJob(id: AnyXoJob['id']): Promise<void>
getAllRemotesInfo(): Promise< getAllRemotesInfo(): Promise<
Record< Record<
XoBackupRepository['id'], XoBackupRepository['id'],
@@ -543,6 +568,19 @@ export type XoApp = EventEmitter & {
): Promise<{ vmUuid: string; success: boolean; merge?: boolean; size?: number; error?: string }[]> ): Promise<{ vmUuid: string; success: boolean; merge?: boolean; size?: number; error?: string }[]>
/** Remove a server from the DB (XCP-ng/XenServer) */ /** Remove a server from the DB (XCP-ng/XenServer) */
unregisterXenServer(id: XoServer['id']): Promise<void> 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( updateUser(
id: XoUser['id'], id: XoUser['id'],
updates: { updates: {

View File

@@ -530,15 +530,22 @@ type XoBackupJobGeneralSettings = {
[key: string]: unknown [key: string]: unknown
} }
/**
* 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 & { export type XoVmBackupJobGeneralSettings = XoBackupJobGeneralSettings & {
cbtDestroySnapshotData?: boolean cbtDestroySnapshotData?: boolean
concurrency?: number concurrency?: number
longTermRetention?: { longTermRetention?: 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> }
}
checkpointSnapshot?: boolean checkpointSnapshot?: boolean
offlineSnapshot?: boolean offlineSnapshot?: boolean
maxExportRate?: number maxExportRate?: number
@@ -595,13 +602,12 @@ export type XoMetadataBackupJob = BaseXoJob & {
export type XoMirrorBackupGeneralSettings = XoBackupJobGeneralSettings & { export type XoMirrorBackupGeneralSettings = XoBackupJobGeneralSettings & {
concurrency?: number concurrency?: number
longTermRetention?: XoBackupJobLongTermRetention
nRetriesVmBackupFailures?: number nRetriesVmBackupFailures?: number
mergeBackupsSynchronously?: boolean mergeBackupsSynchronously?: boolean
timeout?: number timeout?: number
timezone?: string timezone?: string
maxExportRate?: number maxExportRate?: number
backupReportTpl?: 'compactMjml'
reportWhen: 'failure'
} }
export type XoMirrorBackupScheduleSettings = { export type XoMirrorBackupScheduleSettings = {
exportRetention?: number 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' import { invalidParameters } from 'xo-common/api-errors.js'
export function assertValidSettingsExpression(settings, keys) { export function assertValidSettingsExpression(settings, keys) {

View File

@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import { describe, it } from 'node:test' import { describe, it } from 'node:test'
import { invalidParameters } from 'xo-common/api-errors.js' 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 VALID = 'vm:tags:prod'
const INVALID = 'vm:(' const INVALID = 'vm:('

View File

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

View File

@@ -163,8 +163,8 @@ yarn workspace @xen-orchestra/qa-test demo
├── backup.config.js # Backup job configuration generator ├── backup.config.js # Backup job configuration generator
├── client/ ├── client/
│ ├── dispatchClient.js # Central hub: orchestrates connections and handlers │ ├── dispatchClient.js # Central hub: orchestrates connections and handlers
│ ├── restApiClient.js # HTTP REST API client (reads) │ ├── restApiClient.js # HTTP REST API client
│ ├── xoLibClient.js # WebSocket connection via xo-lib (writes) │ ├── xoLibClient.js # WebSocket connection via xo-lib (writes not in REST)
│ ├── cleanupClient.js # Automatic test resource cleanup │ ├── cleanupClient.js # Automatic test resource cleanup
│ ├── FilterBuilder.js # REST API filter builder │ ├── FilterBuilder.js # REST API filter builder
│ └── requests/ # Specialized handlers per resource │ └── 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: The `DispatchClient` is the main entry point. It orchestrates:
- **REST API** (`restApiClient`) for reads (GET) - **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 writes (JSON-RPC calls) - **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. 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 ├── backup.config.js # Backup job configuration generator
├── client/ ├── client/
│ ├── dispatchClient.js # Central hub: orchestrates connections and handlers │ ├── dispatchClient.js # Central hub: orchestrates connections and handlers
│ ├── restApiClient.js # HTTP REST API client (reads) │ ├── restApiClient.js # HTTP REST API client
│ ├── xoLibClient.js # WebSocket connection via xo-lib (writes) │ ├── xoLibClient.js # WebSocket connection via xo-lib (writes not in REST)
│ ├── cleanupClient.js # Automatic test resource cleanup │ ├── cleanupClient.js # Automatic test resource cleanup
│ ├── FilterBuilder.js # REST API filter builder │ ├── FilterBuilder.js # REST API filter builder
│ └── requests/ # Specialized handlers per resource │ └── 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: The `DispatchClient` is the main entry point. It orchestrates:
- **REST API** (`restApiClient`) for reads (GET) - **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 writes (JSON-RPC calls) - **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. 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 deletes any type of backup job, along with its schedules
// The REST API may not expose the type field for mirror jobs, so fall back when backupNg try {
// returns "no such job" (error.data.type === 'job'). await this.dispatchClient.backup.deleteBackupJob(job.id)
if (job.type === 'mirrorBackup') { } catch (error) {
await this.dispatchClient.backup.deleteMirrorBackupJob(job.id) if (error.status !== 404) {
} else { throw error
try {
await this.dispatchClient.backup.deleteBackupJob(job.id)
} catch (error) {
if (error?.data?.type !== 'job') {
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 findSchedules = async () => {
const allSchedules = await this.dispatchClient.xoClient.call('schedule.getAll') const allSchedules = await this.dispatchClient.backup.listSchedules()
const schedulesArray = Array.isArray(allSchedules) ? allSchedules : Object.values(allSchedules) return allSchedules.filter(
return schedulesArray.filter(
schedule => schedule =>
config.includeIds.includes(schedule.id) || config.includeIds.includes(schedule.id) ||
config.namePatterns.some(pattern => schedule.name?.includes(pattern)) config.namePatterns.some(pattern => schedule.name?.includes(pattern))
@@ -365,7 +348,7 @@ export class CleanupClient {
} }
const deleteSchedule = async schedule => { 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, { 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') 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. * Specialized request handler for XenOrchestra backup operations.
* *
* Provides comprehensive backup job management with REST API for read operations * Backup jobs (VM, mirror and metadata) and their schedules are created, updated, deleted
* and WebSocket for creation/execution operations. Handles job lifecycle, * and run through the REST API. Backup archive operations that are not exposed by the
* execution monitoring, and log retrieval. * REST API yet still use the WebSocket API.
* *
* @class BackupRequest * @class BackupRequest
* @extends AbstractRequest * @extends AbstractRequest
*/ */
export class BackupRequest extends AbstractRequest { export class BackupRequest extends AbstractRequest {
/** /**
* REST API endpoint for backup jobs * REST API endpoint for backup jobs (all types: backup, mirrorBackup, metadataBackup)
* Note: This is primarily used for read operations
* @type {string} * @type {string}
*/ */
endpoint = 'backup-jobs' 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. * `config.schedules` are keyed by temporary IDs, which can be used as keys in
* REST API doesn't support backup job creation operations. * `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 * @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() 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 { try {
const result = await this.dispatchClient.xoClient.call('backupNg.createJob', xoConfig) const { id } = await this.dispatchClient.restApiClient.post(`/rest/v0/${this.endpoint}`, { ...config, type })
return result return id
} catch (error) { } 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 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 * @param {string} jobId - ID of the backup job to delete
* @returns {Promise<Object>} Deletion result * @returns {Promise<void>}
* @throws {Error} If deletion fails or job ID is invalid * @throws {Error} If deletion fails, `error.status` is 404 if the job does not exist
*/ */
async deleteBackupJob(jobId) { async deleteBackupJob(jobId) {
this._ensureConnected() this._ensureConnected()
@@ -89,19 +158,127 @@ export class BackupRequest extends AbstractRequest {
throw new Error('Valid backup job ID is required') throw new Error('Valid backup job ID is required')
} }
// Backup job deletion is only available via WebSocket API
try { try {
const result = await this.dispatchClient.xoClient.call('backupNg.deleteJob', { id: jobId }) await this.dispatchClient.restApiClient.delete(`/rest/v0/${this.endpoint}/${jobId}`)
return result
} catch (error) { } catch (error) {
// "no such job" means the caller used the wrong delete method (e.g. mirror job) — let it handle the fallback // a missing job is handled by the caller (e.g. cleanup of an already deleted job)
if (error?.data?.type !== 'job') { if (error.status !== 404) {
log.warn('Delete backup job failed', { error }) log.warn('Delete backup job failed', { jobId, message: error.message, body: error.body })
} }
throw error 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. * 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. * Lists metadata backups for the given backup repositories.
* *
@@ -525,74 +438,4 @@ export class BackupRequest extends AbstractRequest {
throw error 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() 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. * Basic GET request method.
* *
@@ -157,7 +172,7 @@ export class RestApiClient {
const text = await response.text() const text = await response.text()
return text ? JSON.parse(text) : {} return text ? JSON.parse(text) : {}
} }
throw new Error(`HTTP ${response.status}: ${response.statusText}`) throw await RestApiClient.#httpError(response)
} catch (error) { } catch (error) {
log.warn('GET request failed on ' + endpoint, { error }) log.warn('GET request failed on ' + endpoint, { error })
throw error throw error
@@ -190,7 +205,7 @@ export class RestApiClient {
const text = await response.text() const text = await response.text()
return text ? JSON.parse(text) : {} return text ? JSON.parse(text) : {}
} }
throw new Error(`HTTP ${response.status}: ${response.statusText}`) throw await RestApiClient.#httpError(response)
} catch (error) { } catch (error) {
log.warn(`${method} request failed`, { error }) log.warn(`${method} request failed`, { error })
throw error throw error
@@ -255,7 +270,7 @@ export class RestApiClient {
const text = await response.text() const text = await response.text()
return text ? JSON.parse(text) : {} return text ? JSON.parse(text) : {}
} }
throw new Error(`HTTP ${response.status}: ${response.statusText}`) throw await RestApiClient.#httpError(response)
} catch (error) { } catch (error) {
log.warn('DELETE request failed', { error }) log.warn('DELETE request failed', { error })
throw error throw error

View File

@@ -134,7 +134,7 @@ describe('Mirror Backup - Full Remote', () => {
assert.ok(scheduleKey, 'Mirror schedule key is required') assert.ok(scheduleKey, 'Mirror schedule key is required')
tracker.trackResource('schedule', scheduleKey, { name, backupJobId: jobId }) 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}`) assertBackupSuccess(result, `Mirror backup: ${name}`)
return result 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 // Retention — source retention > destination retention
// ========================================================================= // =========================================================================

View File

@@ -117,6 +117,36 @@ describe('Backup basic tests', () => {
assert(backupJob.name === name) 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', () => { describe('Run backup jobs', () => {
it('should run the backup job in delta mode with health checks', async () => { it('should run the backup job in delta mode with health checks', async () => {
await createBackupJobForTest(defaultConfig, 'delta') 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. * 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 * @param {'pool'|'xo'} type - Metadata task type to assert on
*/ */
function assertMetadataTaskSuccess(logEntry, type) { function assertMetadataTaskSuccess(logEntry, type) {
@@ -112,19 +112,15 @@ describe('Metadata backup tests', () => {
assert.ok(jobId, 'createMetadataBackupJob should return a job ID') 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.strictEqual(job.name, name, 'Job name should match')
assert.ok(job.pools, 'Job should have pools configured') assert.ok(job.pools, 'Job should have pools configured')
assert.strictEqual(job.xoMetadata, true, 'Job should have xoMetadata enabled') assert.strictEqual(job.xoMetadata, true, 'Job should have xoMetadata enabled')
log.debug('Metadata backup job created and verified', { jobId, name }) log.debug('Metadata backup job created and verified', { jobId, name })
// Cleanup: this test only validates creation, delete the job immediately // Cleanup: this test only validates creation, delete the job (and its schedule) immediately
await dispatchClient.backup.deleteMetadataBackupJob(jobId) await dispatchClient.backup.deleteBackupJob(jobId)
const scheduleKey = getScheduleKey(job)
if (scheduleKey) {
await dispatchClient.xoClient.call('schedule.delete', { id: scheduleKey })
}
log.debug('Metadata backup job cleaned up', { jobId }) log.debug('Metadata backup job cleaned up', { jobId })
}) })
}) })
@@ -150,7 +146,7 @@ describe('Metadata backup tests', () => {
xoMetadata: true, xoMetadata: true,
}) })
const job = await dispatchClient.backup.getMetadataJob(jobId) const job = await dispatchClient.backup.details(jobId)
scheduleKey = getScheduleKey(job) scheduleKey = getScheduleKey(job)
assert.ok(scheduleKey, 'Metadata backup job should have a schedule key after creation') assert.ok(scheduleKey, 'Metadata backup job should have a schedule key after creation')
@@ -160,21 +156,13 @@ describe('Metadata backup tests', () => {
after(async () => { after(async () => {
if (jobId) { if (jobId) {
try { try {
await dispatchClient.backup.deleteMetadataBackupJob(jobId) // also deletes its schedule
await dispatchClient.backup.deleteBackupJob(jobId)
log.debug('Deleted metadata backup job', { jobId }) log.debug('Deleted metadata backup job', { jobId })
} catch (error) { } catch (error) {
log.warn('Failed to delete metadata backup job', { jobId, 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 () => { 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 }) 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`) assertBackupSuccess(result, `Metadata backup run ${runIndex}/3`)
assertMetadataTaskSuccess(result, 'pool') assertMetadataTaskSuccess(result, 'pool')

View File

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

View File

@@ -10,21 +10,26 @@ import { createLogger } from '@xen-orchestra/log'
import { inject } from 'inversify' import { inject } from 'inversify'
import { noSuchObject } from 'xo-common/api-errors.js' import { noSuchObject } from 'xo-common/api-errors.js'
import { import {
Body,
Delete,
Deprecated, Deprecated,
Example, Example,
Extension, Extension,
Get, Get,
Hidden, Hidden,
Middlewares, Middlewares,
Patch,
Path, Path,
Post,
Query, Query,
Request, Request,
Response, Response,
Route, Route,
Security, Security,
SuccessResponse,
Tags, Tags,
} from 'tsoa' } 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 { provide } from 'inversify-binding-decorators'
import { acl, autoBindService } from '../middlewares/acl.middleware.mjs' 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 { BackupLogService } from '../backup-logs/backup-log.service.mjs'
import { import {
badRequestResp, badRequestResp,
createdResp,
forbiddenOperationResp, forbiddenOperationResp,
invalidParameters,
noContentResp,
notFoundResp, notFoundResp,
unauthorizedResp, unauthorizedResp,
Unbrand, Unbrand,
@@ -40,7 +48,9 @@ import {
import { RestApi } from '../rest-api/rest-api.mjs' import { RestApi } from '../rest-api/rest-api.mjs'
import { BASE_URL, limitAndFilterArray, safeParseComplexMatcher } from '../helpers/utils.helper.mjs' import { BASE_URL, limitAndFilterArray, safeParseComplexMatcher } from '../helpers/utils.helper.mjs'
import type { import type {
CreateBackupJobBody,
UnbrandAnyXoBackupJob, UnbrandAnyXoBackupJob,
UpdateBackupJobBody,
UnbrandXoMetadataBackupJob, UnbrandXoMetadataBackupJob,
UnbrandXoMirrorBackupJob, UnbrandXoMirrorBackupJob,
UnbrandXoVmBackupJob, UnbrandXoVmBackupJob,
@@ -48,6 +58,7 @@ import type {
import { SendObjects } from '../helpers/helper.type.mjs' import { SendObjects } from '../helpers/helper.type.mjs'
import { XoController } from '../abstract-classes/xo-controller.mjs' import { XoController } from '../abstract-classes/xo-controller.mjs'
import { import {
backupJobId,
metadataBackupJob, metadataBackupJob,
metadataBackupJobIds, metadataBackupJobIds,
mirrorBackupJob, mirrorBackupJob,
@@ -138,6 +149,79 @@ export class BackupJobController extends XoController<AnyXoBackupJob> {
getBackupJob(@Path() id: string): Promise<UnbrandAnyXoBackupJob> { getBackupJob(@Path() id: string): Promise<UnbrandAnyXoBackupJob> {
return this.getObject(id as AnyXoBackupJob['id']) 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)-------------------- // ----------- DEPRECATED TO BE REMOVED IN ONE YEAR (09-12-2026)--------------------

View File

@@ -1,10 +1,20 @@
import { createPredicate } from 'value-matcher' import { createPredicate } from 'value-matcher'
import { assertValidExpression, assertValidSettingsExpression } from '@xen-orchestra/backups/backupExpressions.mjs'
import { extractIdsFromSimplePattern } from '@xen-orchestra/backups/extractIdsFromSimplePattern.mjs' import { extractIdsFromSimplePattern } from '@xen-orchestra/backups/extractIdsFromSimplePattern.mjs'
import { noSuchObject } from 'xo-common/api-errors.js' import { invalidParameters, noSuchObject } from 'xo-common/api-errors.js'
import type { XoVmBackupJob, XoVm, XoSchedule, AnyXoJob, AnyXoBackupJob } from '@vates/types' import type {
XoVmBackupJob,
XoVm,
XoSchedule,
AnyXoJob,
AnyXoBackupJob,
XoMetadataBackupJob,
XoMirrorBackupJob,
} from '@vates/types'
import type { RestApi } from '../rest-api/rest-api.mjs' 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 { export class BackupJobService {
#backupJobTypes = ['backup', 'metadataBackup', 'mirrorBackup'] #backupJobTypes = ['backup', 'metadataBackup', 'mirrorBackup']
@@ -23,6 +33,51 @@ export class BackupJobService {
return job 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 { isBackupJob(anyJob: AnyXoJob): anyJob is AnyXoBackupJob {
return this.#backupJobTypes.includes(anyJob.type) 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 UnbrandXoMirrorBackupJob = Unbrand<UnbrandedXoMirrorBackupJob>
export type UnbrandAnyXoBackupJob = UnbrandXoVmBackupJob | UnbrandXoMetadataBackupJob | UnbrandXoMirrorBackupJob 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 * as CM from 'complex-matcher'
import { AnyXoVm, XoSr } from '@vates/types' import { AnyXoVm, XoSr } from '@vates/types'
import { createLogger } from '@xen-orchestra/log' 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 { isPromise } from 'node:util/types'
import { MaybePromise, PromiseWriteInStreamError } from './helper.type.mjs' 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 isSrWritableOrIso = (sr: XoSr) => sr.size > 0
export const isReplicaVm = (vm: AnyXoVm) => 'start' in vm.blockedOperations && vm.other['xo:backup:job'] !== undefined 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') 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,>( export const getTopPerProperty = <T,>(
array: T[], array: T[],
{ length = Infinity, prop }: { length?: number; prop: keyof T & string } { length = Infinity, prop }: { length?: number; prop: keyof T & string }

View File

@@ -134,3 +134,7 @@ export const mirrorBackupJob = {
proxy: '83050a39-44e2-4e59-b612-860250ce9338', proxy: '83050a39-44e2-4e59-b612-860250ce9338',
id: 'e680c14c-ab52-45c8-bb0e-bd4ca12ea8f9', 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 = { export const createNetwork = {
id: '9fe12ca3-d75d-cfb0-492e-cfd2bc6c568f', 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 { import {
Body,
Delete,
Example, Example,
Extension, Extension,
Get, Get,
Middlewares, Middlewares,
Patch,
Path, Path,
Post, Post,
Query, Query,
@@ -16,26 +19,31 @@ import {
} from 'tsoa' } from 'tsoa'
import { inject } from 'inversify' import { inject } from 'inversify'
import { provide } from 'inversify-binding-decorators' 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 { acl } from '../middlewares/acl.middleware.mjs'
import { import {
asynchronousActionResp, asynchronousActionResp,
badRequestResp, badRequestResp,
createdResp,
featureUnauthorized, featureUnauthorized,
forbiddenOperationResp, forbiddenOperationResp,
internalServerErrorResp, internalServerErrorResp,
invalidParameters,
noContentResp, noContentResp,
notFoundResp, notFoundResp,
unauthorizedResp, unauthorizedResp,
type Unbrand, type Unbrand,
} from '../open-api/common/response.common.mjs' } 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 { taskLocation } from '../open-api/oa-examples/task.oa-example.mjs'
import type { SendObjects } from '../helpers/helper.type.mjs' import type { SendObjects } from '../helpers/helper.type.mjs'
import { XoController } from '../abstract-classes/xo-controller.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 type { CreateActionReturnType } from '../abstract-classes/base-controller.mjs'
import { RestApi } from '../rest-api/rest-api.mjs' import { RestApi } from '../rest-api/rest-api.mjs'
import type { CreateScheduleBody, UpdateScheduleBody } from './schedule.type.mjs'
@Route('schedules') @Route('schedules')
@Security('*') @Security('*')
@@ -152,4 +160,106 @@ export class ScheduleController extends XoController<XoSchedule> {
taskProperties: { name: 'run schedule', objectId: scheduleId }, 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/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)) - [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)) - [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 ### Bug fixes
@@ -44,7 +45,7 @@
- @vates/types minor - @vates/types minor
- @xen-orchestra/backups minor - @xen-orchestra/backups minor
- @xen-orchestra/qa-test patch - @xen-orchestra/qa-test patch
- @xen-orchestra/rest-api patch - @xen-orchestra/rest-api minor
- @xen-orchestra/web minor - @xen-orchestra/web minor
- @xen-orchestra/web-core minor - @xen-orchestra/web-core minor
- xo-server minor - xo-server minor

View File

@@ -5,7 +5,7 @@ import { pipeline } from 'readable-stream'
import createNdJsonStream from '../_createNdJsonStream.mjs' import createNdJsonStream from '../_createNdJsonStream.mjs'
import { REMOVE_CACHE_ENTRY } from '../_pDebounceWithKey.mjs' import { REMOVE_CACHE_ENTRY } from '../_pDebounceWithKey.mjs'
import { safeDateFormat } from '../utils.mjs' import { safeDateFormat } from '../utils.mjs'
import { assertValidSettingsExpression, assertValidExpression } from './_backupExpressions.mjs' import { assertValidSettingsExpression, assertValidExpression } from '@xen-orchestra/backups/backupExpressions.mjs'
const SCHEMA_SETTINGS = { const SCHEMA_SETTINGS = {
type: 'object', type: 'object',

View File

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

View File

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