mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
committed by
GitHub
parent
abbe24631d
commit
724402733b
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
@@ -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:('
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)--------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
@xen-orchestra/rest-api/src/schedules/schedule.type.mts
Normal file
15
@xen-orchestra/rest-api/src/schedules/schedule.type.mts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { assertValidSettingsExpression } from './_backupExpressions.mjs'
|
||||
import { assertValidSettingsExpression } from '@xen-orchestra/backups/backupExpressions.mjs'
|
||||
|
||||
const SCHEMA_SETTINGS = {
|
||||
type: 'object',
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user