mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
feat(backups): make live mount restore works with xo-proxy (#10431)
This commit is contained in:
committed by
GitHub
parent
b5f119f1d4
commit
f4fa020d2e
@@ -191,12 +191,6 @@ export type BackupArchiveDiskMount = {
|
||||
port: number
|
||||
}
|
||||
|
||||
/** A live mount, as listed by `listMountedBackupArchiveDisks` */
|
||||
export type MountedBackupArchiveDisk = BackupArchiveDiskMount & {
|
||||
/** Path of the mounted disk on its backup repository */
|
||||
diskPath: string
|
||||
}
|
||||
|
||||
export type XoApp = {
|
||||
hooks: EventEmitter
|
||||
_redis: {
|
||||
@@ -379,6 +373,8 @@ export type XoApp = {
|
||||
getBackupArchiveDiskMountOwner(id: BackupArchiveDiskMount['id']): {
|
||||
archiveId: XoVmBackupArchive['id']
|
||||
hostId: XoHost['id']
|
||||
/** set when the disk is served by a proxy instead of this appliance */
|
||||
proxyId?: XoProxy['id']
|
||||
}
|
||||
getBackupNgLogs(): Promise<Record<string, AnyXoLog>>
|
||||
getBackupNgLogs(id: AnyXoLog['id']): Promise<AnyXoLog>
|
||||
@@ -417,7 +413,6 @@ export type XoApp = {
|
||||
xo: Record<XoBackupRepository['id'], XoConfigBackupArchive[]>
|
||||
pool: Record<XoBackupRepository['id'], Record<XoPool['id'], XoPoolBackupArchive[]>>
|
||||
}>
|
||||
listMountedBackupArchiveDisks(): MountedBackupArchiveDisk[]
|
||||
/** `null` when the listing of a backup repository failed */
|
||||
listVmBackupsNg(
|
||||
backupRepositoryIds: XoBackupRepository['id'][],
|
||||
@@ -426,6 +421,9 @@ export type XoApp = {
|
||||
/**
|
||||
* Serve one disk of a backup archive as a read-only iSCSI LUN and attach it to
|
||||
* `host` as an SR. Undone by `unmountBackupArchiveDisk`.
|
||||
*
|
||||
* The LUN is served by whoever can read the backup repository: this appliance, or the proxy the
|
||||
* repository is linked to.
|
||||
*/
|
||||
mountBackupArchiveDisk(params: {
|
||||
archiveId: XoVmBackupArchive['id']
|
||||
@@ -434,6 +432,16 @@ export type XoApp = {
|
||||
hostId: XoHost['id']
|
||||
}): Promise<BackupArchiveDiskMount>
|
||||
pingRemote(id: XoBackupRepository['id']): Promise<{ success: true }>
|
||||
/**
|
||||
* Record the live mounts a proxy created by itself, while running a restore: they never went
|
||||
* through `mountBackupArchiveDisk`, so nothing else knows which proxy serves them.
|
||||
*/
|
||||
registerProxyBackupArchiveDiskMounts(params: {
|
||||
archiveId: XoVmBackupArchive['id']
|
||||
/** as reported by the restore, each with the host it is attached to */
|
||||
mounts: { id: BackupArchiveDiskMount['id']; hostId: XoHost['id'] }[]
|
||||
proxyId: XoProxy['id']
|
||||
}): void
|
||||
/** Allow to add a new server in the DB (XCP-ng/XenServer) */
|
||||
registerXenServer(
|
||||
body: Pick<XoServer, 'host' | 'httpProxy' | 'label' | 'username'> & {
|
||||
|
||||
@@ -26,8 +26,9 @@ async function resolveUuid(xapi, cache, uuid, type) {
|
||||
return cache.get(uuid)
|
||||
}
|
||||
export class ImportVmBackup {
|
||||
// ids of the live mounts created by this restore, so a failure can release them
|
||||
#liveMountIds = []
|
||||
// live mounts created by this restore, with the host they are attached to, so a failure can
|
||||
// release them and a success can report them to the caller, which is the one keeping track of them
|
||||
#liveMounts = []
|
||||
|
||||
// ref of the host serving the live mounts, resolved once on the destination pool
|
||||
#liveMountHostRef
|
||||
@@ -356,7 +357,7 @@ export class ImportVmBackup {
|
||||
|
||||
const diskPath = join(metadataDir, metadata.vhds[vdiRef])
|
||||
const mount = await liveMount.mountDisk({ diskPath, hostId })
|
||||
this.#liveMountIds.push(mount.id)
|
||||
this.#liveMounts.push({ ...mount, hostId })
|
||||
info('disk live mounted', { diskPath, hostId, mountId: mount.id, vdiUuid: vdi.uuid })
|
||||
|
||||
const record = { ...vdi, liveMountedVdiRef: await xapi.call('VDI.get_by_uuid', mount.vdiUuid) }
|
||||
@@ -374,7 +375,7 @@ export class ImportVmBackup {
|
||||
* Their SR is plugged on that host only, so the VM can run nowhere else.
|
||||
*/
|
||||
async #setLiveMountAffinity(vmRef) {
|
||||
if (this.#liveMountIds.length === 0) {
|
||||
if (this.#liveMounts.length === 0) {
|
||||
return
|
||||
}
|
||||
await this._xapi.call('VM.set_affinity', vmRef, await this.#resolveLiveMountHostRef())
|
||||
@@ -387,7 +388,7 @@ export class ImportVmBackup {
|
||||
* hide the error which caused it.
|
||||
*/
|
||||
async #releaseLiveMounts() {
|
||||
for (const id of this.#liveMountIds.splice(0)) {
|
||||
for (const { id } of this.#liveMounts.splice(0)) {
|
||||
try {
|
||||
await this._liveMount.unmountDisk(id)
|
||||
} catch (error) {
|
||||
@@ -473,7 +474,7 @@ export class ImportVmBackup {
|
||||
return {
|
||||
size,
|
||||
id: await xapi.getField('VM', vmRef, 'uuid'),
|
||||
liveMountIds: [...this.#liveMountIds],
|
||||
liveMounts: [...this.#liveMounts],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -124,8 +124,7 @@ class VdiRestoreTargets {
|
||||
* Whether a raw `mapVdisSrs` setting asks for at least one live mount.
|
||||
*
|
||||
* Kept separate from the normalization so the code paths that simply cannot honor a live mount
|
||||
* (backup health check, a remote handled by a proxy, a full backup) can reject it without
|
||||
* validating the rest of the setting.
|
||||
* (backup health check, a full backup) can reject it without validating the rest of the setting.
|
||||
*
|
||||
* @param {object} [mapVdisSrs]
|
||||
* @returns {boolean}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createLogger } from '@xen-orchestra/log'
|
||||
import { DiskBlockDevice, IscsiTarget } from '@vates/iscsi'
|
||||
import { defer } from 'golike-defer'
|
||||
import { openDiskChain } from '@xen-orchestra/backup-archive/disks'
|
||||
import { noSuchObject } from 'xo-common/api-errors.js'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
import { detectLocalAddress } from './_address.mjs'
|
||||
@@ -171,7 +172,8 @@ export default class LiveMount {
|
||||
async unmountDisk(id) {
|
||||
const mount = this.#mounts.get(id)
|
||||
if (mount === undefined) {
|
||||
throw new Error(`no such live mount ${id}`)
|
||||
// a coded error, so a remote caller (XO driving a proxy) can tell it apart from a failed call
|
||||
noSuchObject(id, 'live-mount')
|
||||
}
|
||||
// drop it first, so a failing teardown cannot be retried against a
|
||||
// half-released mount
|
||||
|
||||
@@ -305,7 +305,7 @@ describe('unmountDisk', () => {
|
||||
|
||||
it('rejects an unknown mount', async () => {
|
||||
const { mixin } = makeMixin()
|
||||
await assert.rejects(mixin.unmountDisk('nope'), /no such live mount nope/)
|
||||
await assert.rejects(mixin.unmountDisk('nope'), { code: 1, data: { id: 'nope', type: 'live-mount' } })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Config from '@xen-orchestra/mixins/Config.mjs'
|
||||
import Hooks from '@xen-orchestra/mixins/Hooks.mjs'
|
||||
import HttpProxy from '@xen-orchestra/mixins/HttpProxy.mjs'
|
||||
import LiveMount from '@xen-orchestra/mixins/live-mount/index.mjs'
|
||||
import SslCertificate from '@xen-orchestra/mixins/SslCertificate.mjs'
|
||||
import mixin from '@xen-orchestra/mixin'
|
||||
import { createDebounceResource } from '@vates/disposable/debounceResource.js'
|
||||
@@ -25,6 +26,7 @@ export default class App {
|
||||
Config,
|
||||
Hooks,
|
||||
HttpProxy,
|
||||
LiveMount,
|
||||
Logs,
|
||||
Remotes,
|
||||
ReverseProxy,
|
||||
|
||||
@@ -191,7 +191,28 @@ export default class Backups {
|
||||
} = await Disposable.all([this.getAdapter(remote), this.getXapi(xapiOpts)])
|
||||
|
||||
const metadata = await adapter.readVmBackupMetadata(backupId)
|
||||
const run = () => new ImportVmBackup({ adapter, metadata, settings, srUuid, xapi }).run()
|
||||
const run = () =>
|
||||
new ImportVmBackup({
|
||||
adapter,
|
||||
// a live mount outlives the restore which created it, so it cannot share the
|
||||
// resources disposed at the end of this call: `#mountDisk` takes its own and
|
||||
// releases them on unmount
|
||||
liveMount: {
|
||||
mountDisk: ({ diskPath, hostId }) =>
|
||||
this.#mountDisk({
|
||||
diskPath,
|
||||
hostUuid: hostId,
|
||||
nameLabel: `[XO backup] ${metadata.vm.name_label}`,
|
||||
remote,
|
||||
xapi: xapiOpts,
|
||||
}),
|
||||
unmountDisk: mountId => app.liveMount.unmountDisk(mountId),
|
||||
},
|
||||
metadata,
|
||||
settings,
|
||||
srUuid,
|
||||
xapi,
|
||||
}).run()
|
||||
|
||||
if (streamLogs) {
|
||||
return runWithLogs(
|
||||
@@ -341,6 +362,20 @@ export default class Backups {
|
||||
},
|
||||
},
|
||||
],
|
||||
mountDisk: [
|
||||
({ disk, host, nameLabel, remote, xapi }) =>
|
||||
this.#mountDisk({ diskPath: disk, hostUuid: host, nameLabel, remote, xapi }),
|
||||
{
|
||||
description: 'serve a disk of a backup repository as a read-only iSCSI LUN, attached to a host as an SR',
|
||||
params: {
|
||||
disk: { type: 'string' },
|
||||
host: { type: 'string' },
|
||||
nameLabel: { type: 'string', optional: true },
|
||||
remote: { type: 'object' },
|
||||
xapi: { type: 'object' },
|
||||
},
|
||||
},
|
||||
],
|
||||
restoreMetadataBackup: [
|
||||
({ backupId, remote, xapi: xapiOptions }) =>
|
||||
Disposable.use(app.remotes.getHandler(remote), xapiOptions && this.getXapi(xapiOptions), (handler, xapi) =>
|
||||
@@ -387,6 +422,15 @@ export default class Backups {
|
||||
},
|
||||
},
|
||||
],
|
||||
unmountDisk: [
|
||||
({ id }) => app.liveMount.unmountDisk(id),
|
||||
{
|
||||
description: 'detach a disk mounted by mountDisk and stop serving it',
|
||||
params: {
|
||||
id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -420,6 +464,41 @@ export default class Backups {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a disk of a backup repository as a read-only iSCSI LUN and attach it, as an SR, to a
|
||||
* host of the pool `xapiOpts` points at.
|
||||
*
|
||||
* The adapter and the XAPI connection are acquired here but released by the unmount, not by the
|
||||
* call which created the mount: the mount outlives it and needs both until it is torn down —
|
||||
* the handler to serve every read, the XAPI connection to forget the SR.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {string} params.diskPath - path of the disk on the backup repository
|
||||
* @param {string} params.hostUuid - uuid of the host the disk is attached to
|
||||
* @param {string} [params.nameLabel] - name of the created SR
|
||||
* @param {object} params.remote - backup repository holding the disk
|
||||
* @param {object} params.xapi - connection options of the pool owning `hostUuid`
|
||||
*/
|
||||
async #mountDisk({ diskPath, hostUuid, nameLabel, remote, xapi: xapiOpts }) {
|
||||
const {
|
||||
dispose,
|
||||
value: [adapter, xapi],
|
||||
} = await Disposable.all([this.getAdapter(remote), this.getXapi(xapiOpts)])
|
||||
try {
|
||||
return await this._app.liveMount.mountDisk({
|
||||
diskPath,
|
||||
handler: adapter.handler,
|
||||
hostRef: await xapi.call('host.get_by_uuid', hostUuid),
|
||||
nameLabel,
|
||||
release: dispose,
|
||||
xapi,
|
||||
})
|
||||
} catch (error) {
|
||||
await dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
*getAdapter(remote) {
|
||||
const app = this._app
|
||||
return new RemoteAdapter(yield app.remotes.getHandler(remote), {
|
||||
|
||||
@@ -44,6 +44,20 @@ cert = '/var/lib/xo-proxy/certificate.pem'
|
||||
key = '/var/lib/xo-proxy/key.pem'
|
||||
port = 443
|
||||
|
||||
[iscsi]
|
||||
# Address this proxy is reachable at *from the hosts*: it is handed to them as
|
||||
# the iSCSI portal when a disk of one of its backup repositories is live
|
||||
# mounted, so a value they cannot reach makes the mount fail.
|
||||
#
|
||||
# When unset, it is auto-detected per mount (the local address the OS would
|
||||
# route through to reach the target host) — usually correct, but not
|
||||
# guaranteed if this proxy is behind NAT or asymmetric routing. Set this
|
||||
# explicitly if mounts fail to connect despite auto-detection.
|
||||
#advertisedAddress = '192.168.0.1'
|
||||
|
||||
# Address the iSCSI targets bind to. Unset means all interfaces.
|
||||
#bindAddress = '0.0.0.0'
|
||||
|
||||
[logs]
|
||||
# Display all logs matching this filter, regardless of their level
|
||||
#filter = 'xo:backups:*'
|
||||
|
||||
@@ -134,10 +134,35 @@ declare namespace backup {
|
||||
vdiUuid: string
|
||||
// IQN of the target serving the disk
|
||||
iqn: string
|
||||
// address of the portal, as advertised to the host
|
||||
address: string
|
||||
// port of the portal, ephemeral: one target per mount
|
||||
port: number
|
||||
}
|
||||
|
||||
interface RestoredVm {
|
||||
// UUID of the restored VM
|
||||
id: string
|
||||
// bytes transferred
|
||||
size: number
|
||||
// disks live mounted instead of transferred, each attached to `hostId` (a host UUID)
|
||||
liveMounts: (MountedDisk & { hostId: string })[]
|
||||
}
|
||||
|
||||
// `mapVdisSrs` gives a target per disk, keyed by the uuid the disk has in the backup:
|
||||
// `{ type: 'restore', sr?: string }`, `{ type: 'ignore' }`, `{ type: 'live-mount', host: string }`,
|
||||
// or an SR uuid / `null` for the first two, in the legacy shape. A live mounted disk is served
|
||||
// by this proxy, like `mountDisk` does, and outlives the restore: it is reported in `liveMounts`,
|
||||
// and its `id` must be handed back to `unmountDisk`.
|
||||
function importVmBackup(_: {
|
||||
backupId: string
|
||||
remote: Remote
|
||||
settings?: { newMacAddresses?: boolean; mapVdisSrs?: object }
|
||||
srUuid: string
|
||||
streamLogs: boolean = false
|
||||
}): string
|
||||
|
||||
xapi: Xapi
|
||||
streamLogs: boolean = false
|
||||
}): RestoredVm // with `streamLogs`, an ndjson stream of the task logs, the result in the end one
|
||||
|
||||
// Serve `disk` as a read-only iSCSI LUN and attach it, as an SR, to `host` — a host of the pool
|
||||
// `xapi` points at. Nothing is copied: every read goes straight to the backup repository, and
|
||||
// writes are refused. Undone by `unmountDisk`.
|
||||
@@ -145,7 +170,28 @@ declare namespace backup {
|
||||
// The portal handed to the host is this proxy's address as seen from it, auto-detected unless
|
||||
// `iscsi.advertisedAddress` is set in the proxy configuration.
|
||||
//
|
||||
|
||||
// There is no method to list the mounts: a proxy is driven by a single XO, which is the one
|
||||
// keeping track of them.
|
||||
function mountDisk(_: {
|
||||
disk: string
|
||||
host: string
|
||||
nameLabel?: string
|
||||
remote: Remote
|
||||
xapi: Xapi
|
||||
}): MountedDisk
|
||||
|
||||
// Fails with a `noSuchObject` error (code 1, `data.type: 'live-mount'`) for an id this proxy does
|
||||
// not serve, e.g. after a restart: the caller can then forget it.
|
||||
function unmountDisk(_: { id: string })
|
||||
|
||||
function listPoolMetadataBackups(_: { remotes: { [id: string]: Remote } }): {
|
||||
[remoteId: string]: { [poolUuid: string]: object[] }
|
||||
}
|
||||
|
||||
function listVmBackups(_: { remotes: { [remoteId: string]: Remote } }): {
|
||||
[remoteId: string]: { [vmUuid: string]: object[] }
|
||||
}
|
||||
|
||||
function listXoMetadataBackups(_: { remotes: { [id: string]: Remote } }): { [remoteId: string]: object[] }
|
||||
|
||||
function run(_: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { invalidParameters, noSuchObject } from 'xo-common/api-errors.js'
|
||||
* @typedef {import('@vates/types').BackupArchiveDiskMount} BackupArchiveDiskMount
|
||||
* @typedef {import('@vates/types').XoBackupRepository} XoBackupRepository
|
||||
* @typedef {import('@vates/types').XoHost} XoHost
|
||||
* @typedef {import('@vates/types').XoProxy} XoProxy
|
||||
* @typedef {import('@vates/types').XoVmBackupArchive} XoVmBackupArchive
|
||||
*/
|
||||
|
||||
@@ -16,22 +17,51 @@ import { invalidParameters, noSuchObject } from 'xo-common/api-errors.js'
|
||||
*/
|
||||
const getBackupRepositoryId = archiveId => archiveId.split('/')[0]
|
||||
|
||||
// JSON-RPC code of a method the proxy does not implement
|
||||
const METHOD_NOT_FOUND = -32601
|
||||
|
||||
/**
|
||||
* A proxy which predates the live mount API answers `method not found`, which says nothing about
|
||||
* what is missing: report the actual reason, an upgrade is needed.
|
||||
*/
|
||||
const wrapProxyError = (error, proxyId) =>
|
||||
error?.code === METHOD_NOT_FOUND
|
||||
? new Error(`the proxy ${proxyId} is too old to live mount a disk, upgrade it`, { cause: error })
|
||||
: error
|
||||
|
||||
/**
|
||||
* Resolution layer between XO objects and the `LiveMount` shared mixin: it
|
||||
* turns a backup archive id + disk id + host id into a remote handler, a disk
|
||||
* path and a XAPI connection. The mounting itself lives in
|
||||
* `@xen-orchestra/mixins/live-mount/` so xo-proxy can reuse it, and so can any
|
||||
* future feature that mounts a disk from somewhere other than a backup.
|
||||
*
|
||||
* A backup repository linked to a proxy is the one case this appliance cannot
|
||||
* serve itself: only the proxy can read it, so the LUN is served *by the
|
||||
* proxy*, which runs the very same mixin behind `backup.mountDisk`.
|
||||
*
|
||||
* Either way, the mounts are tracked here and only here — a proxy is driven by
|
||||
* a single XO, so it has no listing API of its own and is only ever told to
|
||||
* mount and to unmount.
|
||||
*/
|
||||
export default class BackupDiskMountsResolver {
|
||||
/** @type {XoApp} */
|
||||
#app
|
||||
|
||||
// mount id -> { archiveId, hostId }, so a mount id can be resolved back to
|
||||
// the archive/host it actually belongs to, and a caller-supplied archive id
|
||||
// can be checked against the one the mount was created for
|
||||
/** @type {Map<BackupArchiveDiskMount['id'], { archiveId: XoVmBackupArchive['id'], hostId: XoHost['id'] }>} */
|
||||
#mountOwners = new Map()
|
||||
// every live mount this XO created, in creation order, whoever serves it: a mount id resolves
|
||||
// back to the archive/host it belongs to, so a caller-supplied archive id can be checked against
|
||||
// the one the mount was created for, and to the proxy serving it, if any
|
||||
/**
|
||||
* @type {Map<
|
||||
* BackupArchiveDiskMount['id'],
|
||||
* {
|
||||
* archiveId: XoVmBackupArchive['id']
|
||||
* hostId: XoHost['id']
|
||||
* proxyId?: XoProxy['id']
|
||||
* }
|
||||
* >}
|
||||
*/
|
||||
#mounts = new Map()
|
||||
|
||||
/** @param {XoApp} app */
|
||||
constructor(app) {
|
||||
@@ -59,24 +89,18 @@ export default class BackupDiskMountsResolver {
|
||||
}
|
||||
|
||||
const host = app.getObject(hostId, 'host')
|
||||
const nameLabel = `[XO backup] ${archive.vm.name_label}`
|
||||
|
||||
const remote = await app.getRemoteWithCredentials(getBackupRepositoryId(archiveId))
|
||||
const adapter = await app.getBackupsRemoteAdapter(remote)
|
||||
try {
|
||||
const mount = await app.liveMount.mountDisk({
|
||||
diskPath: diskId,
|
||||
handler: adapter.value.handler,
|
||||
hostRef: host._xapiRef,
|
||||
nameLabel: `[XO backup] ${archive.vm.name_label}`,
|
||||
release: () => adapter.dispose(),
|
||||
xapi: app.getXapi(host),
|
||||
})
|
||||
this.#mountOwners.set(mount.id, { archiveId, hostId })
|
||||
return mount
|
||||
} catch (error) {
|
||||
await adapter.dispose()
|
||||
throw error
|
||||
}
|
||||
const proxyId = remote.proxy
|
||||
|
||||
const mount =
|
||||
proxyId === undefined
|
||||
? await this.#mountHere({ diskId, host, nameLabel, remote })
|
||||
: await this.#mountOnProxy({ diskId, host, nameLabel, proxyId, remote })
|
||||
|
||||
this.#mounts.set(mount.id, { archiveId, hostId, proxyId })
|
||||
return mount
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,28 +108,120 @@ export default class BackupDiskMountsResolver {
|
||||
* ACL checks) don't have to trust a caller-supplied archive/host id.
|
||||
*
|
||||
* @param {BackupArchiveDiskMount['id']} id - identifier returned by `mountBackupArchiveDisk`
|
||||
* @returns {{ archiveId: XoVmBackupArchive['id'], hostId: XoHost['id'] }}
|
||||
* @returns {{ archiveId: XoVmBackupArchive['id'], hostId: XoHost['id'], proxyId?: XoProxy['id'] }}
|
||||
*/
|
||||
getBackupArchiveDiskMountOwner(id) {
|
||||
const owner = this.#mountOwners.get(id)
|
||||
if (owner === undefined) {
|
||||
const entry = this.#mounts.get(id)
|
||||
if (entry === undefined) {
|
||||
throw noSuchObject(id, 'backup-archive-disk-mount')
|
||||
}
|
||||
return owner
|
||||
const { archiveId, hostId, proxyId } = entry
|
||||
return { archiveId, hostId, proxyId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the live mounts a proxy created on its own, during a restore it ran itself.
|
||||
*
|
||||
* Such a restore never goes through `mountBackupArchiveDisk`: the whole import happens on the
|
||||
* proxy, mounts included. Without this, the disks would be served but no longer addressable —
|
||||
* nothing would know which proxy to ask to unmount them.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {XoVmBackupArchive['id']} params.archiveId
|
||||
* @param {{ id: BackupArchiveDiskMount['id'], hostId: XoHost['id'] }[]} params.mounts - as reported by the restore
|
||||
* @param {XoProxy['id']} params.proxyId - proxy serving the mounts
|
||||
*/
|
||||
registerProxyBackupArchiveDiskMounts({ archiveId, mounts, proxyId }) {
|
||||
for (const { id, hostId } of mounts) {
|
||||
this.#mounts.set(id, { archiveId, hostId, proxyId })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BackupArchiveDiskMount['id']} id - identifier returned by `mountBackupArchiveDisk`
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
unmountBackupArchiveDisk(id) {
|
||||
this.#mountOwners.delete(id)
|
||||
return this.#app.liveMount.unmountDisk(id)
|
||||
async unmountBackupArchiveDisk(id) {
|
||||
const proxyId = this.#mounts.get(id)?.proxyId
|
||||
|
||||
if (proxyId === undefined) {
|
||||
// the mixin forgets the mount even when its teardown fails: there is nothing left to retry
|
||||
this.#mounts.delete(id)
|
||||
return this.#app.liveMount.unmountDisk(id)
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#app.callProxyMethod(proxyId, 'backup.unmountDisk', { id })
|
||||
} catch (error) {
|
||||
// a proxy which no longer knows the mount (restarted, or a previous teardown failed after
|
||||
// forgetting it) makes the entry stale. Any other failure may not have reached the proxy,
|
||||
// which would still serve the LUN: keep the entry so the unmount can be retried
|
||||
if (noSuchObject.is(error, { type: 'live-mount' })) {
|
||||
this.#mounts.delete(id)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.#mounts.delete(id)
|
||||
}
|
||||
|
||||
/** @returns {import('@vates/types').MountedBackupArchiveDisk[]} */
|
||||
listMountedBackupArchiveDisks() {
|
||||
return this.#app.liveMount.listMountedDisks()
|
||||
/**
|
||||
* Serve the disk from this appliance, which reads the backup repository itself.
|
||||
*
|
||||
* @returns {Promise<BackupArchiveDiskMount>}
|
||||
*/
|
||||
async #mountHere({ diskId, host, nameLabel, remote }) {
|
||||
const app = this.#app
|
||||
const adapter = await app.getBackupsRemoteAdapter(remote)
|
||||
try {
|
||||
return await app.liveMount.mountDisk({
|
||||
diskPath: diskId,
|
||||
handler: adapter.value.handler,
|
||||
hostRef: host._xapiRef,
|
||||
nameLabel,
|
||||
release: () => adapter.dispose(),
|
||||
xapi: app.getXapi(host),
|
||||
})
|
||||
} catch (error) {
|
||||
await adapter.dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Have the proxy serve the disk: it is the only one able to read this backup repository, and it
|
||||
* runs the same mixin. The XAPI credentials travel with the call, like for a restore, since the
|
||||
* SR is introduced by whoever serves the LUN.
|
||||
*
|
||||
* @returns {Promise<BackupArchiveDiskMount>}
|
||||
*/
|
||||
async #mountOnProxy({ diskId, host, nameLabel, proxyId, remote }) {
|
||||
const app = this.#app
|
||||
// httpProxy is ignored when using XO Proxy
|
||||
const {
|
||||
allowUnauthorized,
|
||||
host: url,
|
||||
password,
|
||||
username,
|
||||
} = await app.getXenServerWithCredentials(app.getXenServerIdByObject(host))
|
||||
|
||||
try {
|
||||
return await app.callProxyMethod(proxyId, 'backup.mountDisk', {
|
||||
disk: diskId,
|
||||
host: host.uuid,
|
||||
nameLabel,
|
||||
remote: {
|
||||
url: remote.url,
|
||||
options: remote.options,
|
||||
},
|
||||
xapi: {
|
||||
allowUnauthorized,
|
||||
credentials: { username, password },
|
||||
url,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
throw wrapProxyError(error, proxyId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
123
packages/xo-server/src/xo-mixins/backup-disk-mounts.test.mjs
Normal file
123
packages/xo-server/src/xo-mixins/backup-disk-mounts.test.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { noSuchObject } from 'xo-common/api-errors.js'
|
||||
|
||||
import BackupDiskMountsResolver from './backup-disk-mounts.mjs'
|
||||
|
||||
const archiveId = 'remote-1/xo-vm-backups/vm-1/20260101T000000Z.json'
|
||||
const hostId = 'host-1'
|
||||
const proxyId = 'proxy-1'
|
||||
|
||||
const makeNoSuchLiveMount = id => {
|
||||
try {
|
||||
noSuchObject(id, 'live-mount')
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} [opts]
|
||||
* @param {(method: string, params: object) => Promise<unknown>} [opts.callProxyMethod]
|
||||
* @param {(id: string) => Promise<void>} [opts.unmountDisk] - local `liveMount.unmountDisk`
|
||||
*/
|
||||
function createResolver({ callProxyMethod = async () => {}, unmountDisk = async () => {} } = {}) {
|
||||
const calls = []
|
||||
const app = {
|
||||
async callProxyMethod(id, method, params) {
|
||||
calls.push([id, method, params])
|
||||
return callProxyMethod(method, params)
|
||||
},
|
||||
getObject: () => ({ _xapiRef: 'OpaqueRef:host', uuid: hostId }),
|
||||
getRemoteWithCredentials: async () => ({ url: 'file:///backups' }),
|
||||
getBackupsRemoteAdapter: async () => ({ value: { handler: {} }, dispose: async () => {} }),
|
||||
getXapi: () => ({}),
|
||||
listVmBackupsNg: async () => ({
|
||||
'remote-1': { 'vm-1': [{ id: archiveId, disks: [{ id: 'disk.vhd' }], vm: { name_label: 'vm' } }] },
|
||||
}),
|
||||
liveMount: {
|
||||
mountDisk: async () => ({ id: 'local-mount' }),
|
||||
unmountDisk,
|
||||
},
|
||||
}
|
||||
return { calls, resolver: new BackupDiskMountsResolver(app) }
|
||||
}
|
||||
|
||||
const isKnown = (resolver, id) => {
|
||||
try {
|
||||
resolver.getBackupArchiveDiskMountOwner(id)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (noSuchObject.is(error)) {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
describe('registerProxyBackupArchiveDiskMounts', () => {
|
||||
it('records each mount with its own host and the proxy serving it', () => {
|
||||
const { resolver } = createResolver()
|
||||
resolver.registerProxyBackupArchiveDiskMounts({ archiveId, mounts: [{ id: 'm1', hostId, srUuid: 'x' }], proxyId })
|
||||
|
||||
assert.deepEqual(resolver.getBackupArchiveDiskMountOwner('m1'), { archiveId, hostId, proxyId })
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmountBackupArchiveDisk on a proxy', () => {
|
||||
it('asks the proxy and forgets the mount', async () => {
|
||||
const { calls, resolver } = createResolver()
|
||||
resolver.registerProxyBackupArchiveDiskMounts({ archiveId, mounts: [{ id: 'm1', hostId }], proxyId })
|
||||
|
||||
await resolver.unmountBackupArchiveDisk('m1')
|
||||
|
||||
assert.deepEqual(calls, [[proxyId, 'backup.unmountDisk', { id: 'm1' }]])
|
||||
assert.equal(isKnown(resolver, 'm1'), false)
|
||||
})
|
||||
|
||||
it('keeps the mount when the call fails, so the unmount can be retried', async () => {
|
||||
let fail = true
|
||||
const { resolver } = createResolver({
|
||||
callProxyMethod: async () => {
|
||||
if (fail) {
|
||||
throw new Error('fetch failed')
|
||||
}
|
||||
},
|
||||
})
|
||||
resolver.registerProxyBackupArchiveDiskMounts({ archiveId, mounts: [{ id: 'm1', hostId }], proxyId })
|
||||
|
||||
await assert.rejects(resolver.unmountBackupArchiveDisk('m1'), /fetch failed/)
|
||||
assert.equal(isKnown(resolver, 'm1'), true)
|
||||
|
||||
fail = false
|
||||
await resolver.unmountBackupArchiveDisk('m1')
|
||||
assert.equal(isKnown(resolver, 'm1'), false)
|
||||
})
|
||||
|
||||
it('forgets a mount the proxy no longer knows, and still reports it', async () => {
|
||||
const { resolver } = createResolver({
|
||||
callProxyMethod: async (method, { id }) => {
|
||||
throw makeNoSuchLiveMount(id)
|
||||
},
|
||||
})
|
||||
resolver.registerProxyBackupArchiveDiskMounts({ archiveId, mounts: [{ id: 'm1', hostId }], proxyId })
|
||||
|
||||
await assert.rejects(resolver.unmountBackupArchiveDisk('m1'), error => noSuchObject.is(error))
|
||||
assert.equal(isKnown(resolver, 'm1'), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmountBackupArchiveDisk on this appliance', () => {
|
||||
it('forgets the mount even when the teardown fails', async () => {
|
||||
const { resolver } = createResolver({
|
||||
unmountDisk: async () => {
|
||||
throw new Error('SR_HAS_NO_PBDS')
|
||||
},
|
||||
})
|
||||
const mount = await resolver.mountBackupArchiveDisk({ archiveId, diskId: 'disk.vhd', hostId })
|
||||
assert.deepEqual(resolver.getBackupArchiveDiskMountOwner(mount.id), { archiveId, hostId, proxyId: undefined })
|
||||
|
||||
await assert.rejects(resolver.unmountBackupArchiveDisk(mount.id), /SR_HAS_NO_PBDS/)
|
||||
assert.equal(isKnown(resolver, mount.id), false)
|
||||
})
|
||||
})
|
||||
@@ -521,6 +521,25 @@ export default class BackupNg {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the live mounts a proxy created while restoring a backup on its own.
|
||||
*
|
||||
* A restore delegated to a proxy mounts the disks *on the proxy*, out of reach of
|
||||
* `mountBackupArchiveDisk`: the mounts it reports are recorded here, with the proxy serving
|
||||
* them, so they can be unmounted afterwards.
|
||||
*
|
||||
* @param {XoVmBackupArchive['id']} archiveId
|
||||
* @param {string} proxyId
|
||||
* @param {{ liveMounts?: { id: string, hostId: string }[] }} [result] - result of the restore, as reported by the proxy
|
||||
*/
|
||||
#registerProxyLiveMounts(archiveId, proxyId, result) {
|
||||
const mounts = result?.liveMounts
|
||||
if (mounts === undefined || mounts.length === 0) {
|
||||
return
|
||||
}
|
||||
this._app.registerProxyBackupArchiveDiskMounts({ archiveId, mounts, proxyId })
|
||||
}
|
||||
|
||||
async importVmBackupNg(id, srId, settings) {
|
||||
const app = this._app
|
||||
const xapi = app.getXapi(srId)
|
||||
@@ -533,11 +552,6 @@ export default class BackupNg {
|
||||
try {
|
||||
let result
|
||||
if (remote.proxy !== undefined) {
|
||||
if (hasLiveMountTarget(settings?.mapVdisSrs)) {
|
||||
// a live mount is served by the appliance which created it, and a proxy has no LiveMount
|
||||
throw invalidParameters('a disk cannot be live mounted from a backup repository handled by a proxy')
|
||||
}
|
||||
|
||||
// httpProxy is ignored when using XO Proxy
|
||||
const { allowUnauthorized, host, password, username } = await app.getXenServerWithCredentials(
|
||||
app.getXenServerIdByObject(sr.$id)
|
||||
@@ -585,11 +599,17 @@ export default class BackupNg {
|
||||
}
|
||||
} catch (error) {
|
||||
if (invalidParameters.is(error)) {
|
||||
// this proxy cannot stream the logs, and is therefore too old to live mount a disk:
|
||||
// nothing to register below
|
||||
delete params.streamLogs
|
||||
return app.callProxyMethod(remote.proxy, 'backup.importVmBackup', params)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// the proxy mounted the disks itself, on itself: without this, nothing here would know
|
||||
// which proxy to ask to unmount them
|
||||
this.#registerProxyLiveMounts(id, remote.proxy, result)
|
||||
} else {
|
||||
result = await Disposable.use(app.getBackupsRemoteAdapter(remote), async adapter => {
|
||||
const metadata = await adapter.readVmBackupMetadata(metadataFilename)
|
||||
|
||||
Reference in New Issue
Block a user