feat(backups): make live mount restore works with xo-proxy (#10431)

This commit is contained in:
Florent BEAUCHAMP
2026-09-25 10:15:17 +02:00
committed by GitHub
parent b5f119f1d4
commit f4fa020d2e
12 changed files with 468 additions and 58 deletions

View File

@@ -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'> & {

View File

@@ -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],
}
}
)

View File

@@ -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}

View File

@@ -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

View File

@@ -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' } })
})
})

View File

@@ -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,

View File

@@ -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), {

View File

@@ -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:*'

View File

@@ -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(_: {

View File

@@ -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)
}
}
/**

View 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)
})
})

View File

@@ -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)