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,
|
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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -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:('
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)--------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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/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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user