mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
10 Commits
8025af3cc8
...
feat_reado
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aa29f4817 | ||
|
|
2393c146f0 | ||
|
|
87c6a19fa5 | ||
|
|
cf8aaeb857 | ||
|
|
31a0252228 | ||
|
|
3ada19bb2c | ||
|
|
132a4e28bc | ||
|
|
5442b22dbc | ||
|
|
a229360f4b | ||
|
|
e385ec20fa |
@@ -7,7 +7,7 @@
|
||||
"vates-iscsi": "dist/cli.mjs"
|
||||
},
|
||||
"license": "ISC",
|
||||
"private": true,
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"description": "Minimal iSCSI target and initiator: expose or read a single LUN, with one-way CHAP",
|
||||
"keywords": [
|
||||
@@ -38,6 +38,7 @@
|
||||
"build": "tsc",
|
||||
"clean": "rimraf dist/",
|
||||
"dev": "tsc --watch",
|
||||
"postversion": "npm publish --access public",
|
||||
"prebuild": "rimraf dist/",
|
||||
"predev": "yarn run prebuild",
|
||||
"test": "tsc && node --test \"dist/**/*.test.mjs\"",
|
||||
|
||||
@@ -174,6 +174,28 @@ export type PoolRollingUpdateRecoveryBlocked = {
|
||||
|
||||
export type PoolRollingUpdateRecovery = PoolRollingUpdateRecoveryRun | PoolRollingUpdateRecoveryBlocked
|
||||
|
||||
/** A disk of a backup archive currently served as a read-only iSCSI LUN */
|
||||
export type BackupArchiveDiskMount = {
|
||||
/** Handle to pass to `unmountBackupArchiveDisk` */
|
||||
id: string
|
||||
/** UUID of the SR introduced on the host */
|
||||
srUuid: string
|
||||
/** UUID of the read-only VDI exposing the backup disk */
|
||||
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
|
||||
}
|
||||
|
||||
/** 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: {
|
||||
|
||||
28
@xen-orchestra/mixins/live-mount/_address.mjs
Normal file
28
@xen-orchestra/mixins/live-mount/_address.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createSocket } from 'node:dgram'
|
||||
|
||||
/**
|
||||
* The local address the OS would use to reach `host`, found the same way
|
||||
* `ip route get`/Go's `net.Dial` trick does: "connecting" a UDP socket never
|
||||
* sends a packet on the wire — it only asks the kernel to resolve a route and
|
||||
* bind a local address for it — so this works even when `host` refuses
|
||||
* connections or nothing is listening on `port`.
|
||||
*
|
||||
* Not a guarantee `host` can reach the resolved address back (NAT, asymmetric
|
||||
* routing, a firewall): it answers "what can reach `host`", not "what `host`
|
||||
* can reach", which is why `iscsi.advertisedAddress` remains available as an
|
||||
* override.
|
||||
*/
|
||||
export function detectLocalAddress(host, port = 1) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = createSocket('udp4')
|
||||
socket.once('error', error => {
|
||||
socket.close()
|
||||
reject(error)
|
||||
})
|
||||
socket.connect(port, host, () => {
|
||||
const { address } = socket.address()
|
||||
socket.close()
|
||||
resolve(address)
|
||||
})
|
||||
})
|
||||
}
|
||||
19
@xen-orchestra/mixins/live-mount/_address.test.mjs
Normal file
19
@xen-orchestra/mixins/live-mount/_address.test.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import { detectLocalAddress } from './_address.mjs'
|
||||
|
||||
describe('detectLocalAddress', () => {
|
||||
it('resolves the local address the OS would route through to reach the given host', async () => {
|
||||
// loopback always has a route, in any sandboxed/offline environment
|
||||
const address = await detectLocalAddress('127.0.0.1')
|
||||
assert.equal(address, '127.0.0.1')
|
||||
})
|
||||
|
||||
it('does not require anything to be listening on the target port', async () => {
|
||||
// connecting a UDP socket never sends a packet, so an arbitrary, surely
|
||||
// unused port must not make this fail
|
||||
const address = await detectLocalAddress('127.0.0.1', 65535)
|
||||
assert.equal(address, '127.0.0.1')
|
||||
})
|
||||
})
|
||||
121
@xen-orchestra/mixins/live-mount/_sr.mjs
Normal file
121
@xen-orchestra/mixins/live-mount/_sr.mjs
Normal file
@@ -0,0 +1,121 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
|
||||
import { cacheLabel } from './_utils.mjs'
|
||||
|
||||
const { debug, info, warn } = createLogger('xo:mixins:LiveMount')
|
||||
|
||||
// Raw "LUN per VDI" driver: the LUN becomes a VDI as-is, with no LVM written to
|
||||
// it — the only iSCSI SR type usable on a read-only LUN.
|
||||
const SR_TYPE = 'iscsi'
|
||||
|
||||
// identifies the SRs we created, to recognize leftovers
|
||||
const OC_MOUNT = 'xo:live-mount'
|
||||
|
||||
// Our target exposes exactly one LUN, numbered 0.
|
||||
const LUN_ID = '0'
|
||||
|
||||
// `sm_config` can only be set when a VDI is introduced, never afterwards (it is
|
||||
// StaticRO), which is why this module introduces the VDI itself instead of
|
||||
// letting a scan do it.
|
||||
const IMAGE_FORMAT_RAW = 'raw'
|
||||
|
||||
/**
|
||||
* Introduce the SR for the LUN on `hostRef` and plug it there.
|
||||
*
|
||||
* `SR.introduce`, not `SR.create`: create would scan, and a scan introduces the
|
||||
* VDI itself with an sm_config we could no longer amend.
|
||||
*/
|
||||
export async function introduceSr($defer, { xapi, hostRef, deviceConfig, id, nameLabel, diskPath }) {
|
||||
const srUuid = randomUUID()
|
||||
const srRef = await xapi.call(
|
||||
'SR.introduce',
|
||||
srUuid,
|
||||
nameLabel ?? `[XO live mount] ${id.slice(0, 8)}`,
|
||||
`mount of ${diskPath}`,
|
||||
SR_TYPE,
|
||||
'user',
|
||||
false, // not shared: this mount serves a single host
|
||||
{}
|
||||
)
|
||||
$defer.onFailure(() => xapi.call('SR.forget', srRef))
|
||||
|
||||
const pbdRef = await xapi.call('PBD.create', { host: hostRef, SR: srRef, device_config: deviceConfig })
|
||||
// plugging logs the initiator in and rescans the SCSI bus, which is well past
|
||||
// what the synchronous call timeout allows
|
||||
await xapi.callAsync('PBD.plug', pbdRef)
|
||||
$defer.onFailure(() => forgetSr(xapi, srRef))
|
||||
|
||||
await xapi.setFieldEntry('SR', srRef, 'other_config', OC_MOUNT, id)
|
||||
// A scan would introduce the LUN as a VDI on its own, with an sm_config we
|
||||
// could no longer amend, so make sure none is triggered at boot. Unlike with
|
||||
// `SR_create`, nothing else writes this key here.
|
||||
await xapi.setFieldEntry('SR', srRef, 'other_config', 'auto-scan', 'false')
|
||||
|
||||
return { srRef, srUuid }
|
||||
}
|
||||
|
||||
// SR.forget rather than SR.destroy: the LUN content must not be touched
|
||||
export async function forgetSr(xapi, srRef) {
|
||||
const pbdRefs = await xapi.call('SR.get_PBDs', srRef)
|
||||
await asyncEach(pbdRefs, pbdRef => xapi.callAsync('PBD.unplug', pbdRef), { stopOnError: false })
|
||||
await xapi.call('SR.forget', srRef)
|
||||
}
|
||||
|
||||
/**
|
||||
* Introduce the LUN as a VDI ourselves, which is the only way to choose its
|
||||
* `sm_config`: that field is StaticRO, so a scan-introduced VDI could never be
|
||||
* amended afterwards.
|
||||
*
|
||||
* `LUNid` is what the driver needs to find the device; it fills `SCSIid` and
|
||||
* `backend-kind` in itself, keeping the keys we pass.
|
||||
*
|
||||
* `readOnly` states the intent, but this driver ignores it either way —
|
||||
* `RAWVDI.introduce()` ends in `_db_introduce()`, which builds the record from
|
||||
* the driver's own VDI object, the same reason the resulting uuid is not the
|
||||
* one asked for. The LUN itself is what actually enforces it: it throws on
|
||||
* write, since it is backed by a read-only `DiskBlockDevice`.
|
||||
*/
|
||||
export async function introduceVdi({ xapi, srRef, SCSIid, size, diskPath, readOnly }) {
|
||||
const uuid = randomUUID()
|
||||
await xapi.call(
|
||||
'VDI.introduce',
|
||||
uuid,
|
||||
`${cacheLabel(diskPath)}.raw`,
|
||||
`mount of ${diskPath}`,
|
||||
srRef,
|
||||
'user',
|
||||
false, // sharable
|
||||
readOnly,
|
||||
{}, // other_config
|
||||
uuid, // location: this driver uses the uuid
|
||||
{}, // xenstore_data
|
||||
{ LUNid: LUN_ID, SCSIid, type: IMAGE_FORMAT_RAW },
|
||||
true, // managed
|
||||
size,
|
||||
0, // physical_utilisation: nothing is allocated locally
|
||||
'OpaqueRef:NULL', // metadata_of_pool
|
||||
false, // is_a_snapshot
|
||||
'19700101T00:00:00Z', // snapshot_time
|
||||
'OpaqueRef:NULL' // snapshot_of
|
||||
)
|
||||
|
||||
// The driver derives the VDI uuid from the LUN's serial, so the record may
|
||||
// not carry the uuid we asked for: resolve it by SCSIid instead.
|
||||
const vdiRefs = await xapi.call('SR.get_VDIs', srRef)
|
||||
for (const vdiRef of vdiRefs) {
|
||||
const vdi = await xapi.getRecord('VDI', vdiRef)
|
||||
if (vdi.sm_config.SCSIid !== SCSIid) {
|
||||
continue
|
||||
}
|
||||
// the whole record, to check what the driver kept of what we asked for
|
||||
debug('introduced VDI', vdi)
|
||||
if (vdi.uuid !== uuid) {
|
||||
info('the driver renamed the introduced VDI', { asked: uuid, got: vdi.uuid })
|
||||
}
|
||||
return vdi.uuid
|
||||
}
|
||||
warn('no VDI found for the introduced LUN', { SCSIid, srRef, diskPath })
|
||||
throw new Error(`no VDI found for the introduced LUN when tryng to mount ${diskPath}`)
|
||||
}
|
||||
68
@xen-orchestra/mixins/live-mount/_target.mjs
Normal file
68
@xen-orchestra/mixins/live-mount/_target.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
|
||||
const { warn } = createLogger('xo:mixins:LiveMount')
|
||||
|
||||
// open-iscsi refuses a CHAP secret outside 12-16 characters
|
||||
const CHAP_SECRET_LENGTH = 16
|
||||
|
||||
// SR.probe() answers the LUN list through a fault instead of a result when the
|
||||
// device config does not name a SCSIid yet.
|
||||
const PROBE_LUN_LIST_ERROR = 'SR_BACKEND_FAILURE_107'
|
||||
const PROBE_NO_TARGET_ERROR = 'SR_BACKEND_FAILURE_141'
|
||||
|
||||
// The probe only performs iSCSI discovery, so `lvmoiscsi` is usable to enumerate
|
||||
// LUNs whatever SR type is created afterwards.
|
||||
const PROBE_SR_TYPE = 'lvmoiscsi'
|
||||
|
||||
const parseXml = (() => {
|
||||
const parser = new XMLParser({
|
||||
attributeNamePrefix: '',
|
||||
ignoreAttributes: false,
|
||||
ignoreDeclaration: true,
|
||||
parseTagValue: false,
|
||||
parseAttributeValue: false,
|
||||
})
|
||||
return xml => parser.parse(Buffer.isBuffer(xml) ? xml.toString() : xml)
|
||||
})()
|
||||
|
||||
/** Random CHAP credentials for one mount; `id` only picks a readable username. */
|
||||
export function createChapCredentials(id) {
|
||||
return {
|
||||
user: `xo-${id.slice(0, 8)}`,
|
||||
secret: randomBytes(CHAP_SECRET_LENGTH).toString('base64url').slice(0, CHAP_SECRET_LENGTH),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the host which SCSIid it computes for our LUN. Passing an incomplete
|
||||
* device config makes SR.probe() answer with a fault holding the LUN list.
|
||||
*/
|
||||
export async function probeScsiId({ xapi, hostRef, deviceConfig, address }) {
|
||||
let xml
|
||||
try {
|
||||
const probed = await xapi.call('SR.probe', hostRef, deviceConfig, PROBE_SR_TYPE, {})
|
||||
// it answers the LUN list through a fault, so a plain result is unexpected
|
||||
warn('SR.probe returned instead of reporting the LUN list', { probed })
|
||||
throw new Error('SR.probe should have reported the LUN list')
|
||||
} catch (error) {
|
||||
if (error.code === PROBE_NO_TARGET_ERROR) {
|
||||
const wrapped = new Error(`the host cannot reach the iSCSI target at ${address}, check iscsi.advertisedAddress`)
|
||||
wrapped.cause = error
|
||||
throw wrapped
|
||||
}
|
||||
if (error.code !== PROBE_LUN_LIST_ERROR) {
|
||||
throw error
|
||||
}
|
||||
xml = parseXml(error.params[2])
|
||||
}
|
||||
|
||||
const luns = xml['iscsi-target']?.LUN
|
||||
const lun = Array.isArray(luns) ? luns[0] : luns
|
||||
const SCSIid = lun?.SCSIid?.trim()
|
||||
if (SCSIid === undefined || SCSIid === '') {
|
||||
throw new Error(`no LUN reported by the host for target ${deviceConfig.targetIQN}`)
|
||||
}
|
||||
return SCSIid
|
||||
}
|
||||
8
@xen-orchestra/mixins/live-mount/_utils.mjs
Normal file
8
@xen-orchestra/mixins/live-mount/_utils.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
// the LUN serves the *content* of the source disk, not its container format, so
|
||||
// a label must not keep a `.vhd`/`.alias.vhd` suffix: it would suggest a format
|
||||
// the storage layer would then read differently
|
||||
export const cacheLabel = diskPath =>
|
||||
diskPath
|
||||
.split('/')
|
||||
.pop()
|
||||
.replace(/(\.alias)?\.vhd$/, '')
|
||||
218
@xen-orchestra/mixins/live-mount/index.mjs
Normal file
218
@xen-orchestra/mixins/live-mount/index.mjs
Normal file
@@ -0,0 +1,218 @@
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
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 { randomBytes } from 'node:crypto'
|
||||
|
||||
import { detectLocalAddress } from './_address.mjs'
|
||||
import { createChapCredentials, probeScsiId } from './_target.mjs'
|
||||
import { forgetSr, introduceSr, introduceVdi } from './_sr.mjs'
|
||||
|
||||
const { info, warn } = createLogger('xo:mixins:LiveMount')
|
||||
|
||||
/**
|
||||
* Serve a disk as a read-only iSCSI LUN and attach it, as an SR, to a host —
|
||||
* so its content is usable without copying it first.
|
||||
*
|
||||
* Nothing is cached: every read goes straight to the source, and writes are
|
||||
* refused (the LUN is backed by `@vates/iscsi`'s `DiskBlockDevice`, which is
|
||||
* read-only). Since nothing needs to be plugged into this appliance's own VM,
|
||||
* the mount can target any host reachable by the caller.
|
||||
*
|
||||
* Nothing app-specific is read from `app` apart from `config` and `hooks`: the
|
||||
* source disk, the XAPI connection and the target host are all passed in by
|
||||
* the caller — so both xo-server and xo-proxy can use this mixin, and so can
|
||||
* any future feature built on it (booting a VM straight from a backup,
|
||||
* importing one from another hypervisor), each supplying its own way to open
|
||||
* the source disk.
|
||||
*
|
||||
* The implementation is split by concern, each module private to this
|
||||
* directory: `_target.mjs` (CHAP + the iSCSI target + SCSI probe), `_sr.mjs`
|
||||
* (the SR/VDI introduced on the target host). This file is the only public
|
||||
* surface.
|
||||
*
|
||||
* Public methods are named `*Disk` even though the class itself is generic:
|
||||
* today it only ever mounts one disk at a time, and a future feature mounting
|
||||
* a whole VM (one call per disk, then a VM built on the results) belongs on
|
||||
* its own method rather than squatting on a bare `mount`/`unmount`.
|
||||
*/
|
||||
export default class LiveMount {
|
||||
#app
|
||||
#createTarget
|
||||
#detectAddress
|
||||
#openDisk
|
||||
|
||||
// mount id -> mount record
|
||||
#mounts = new Map()
|
||||
|
||||
// `openDisk`/`createTarget`/`detectAddress` are injectable for tests only,
|
||||
// like xo-server's crypto-credentials mixin does with xenStore/fsPromises
|
||||
constructor(
|
||||
app,
|
||||
{
|
||||
openDisk = openDiskChain,
|
||||
createTarget = options => new IscsiTarget(options),
|
||||
detectAddress = detectLocalAddress,
|
||||
} = {}
|
||||
) {
|
||||
this.#app = app
|
||||
this.#createTarget = createTarget
|
||||
this.#detectAddress = detectAddress
|
||||
this.#openDisk = openDisk
|
||||
|
||||
app.hooks.on('stop', () =>
|
||||
asyncEach(
|
||||
[...this.#mounts.keys()],
|
||||
id =>
|
||||
this.unmountDisk(id).catch(error => {
|
||||
warn('failed to unmount on stop', { error, id })
|
||||
}),
|
||||
{ stopOnError: false }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {import('@xen-orchestra/fs').RemoteHandlerAbstract} params.handler - passed to `openDisk` to open the source disk
|
||||
* @param {string} params.diskPath - path of the source disk, passed to `openDisk`
|
||||
* @param {object} params.xapi - XAPI connection of the pool owning `hostRef`
|
||||
* @param {string} params.hostRef - opaque ref of the host the disk is attached to as an SR
|
||||
* @param {string} [params.nameLabel] - name of the created SR
|
||||
* @param {() => Promise<void>} [params.release] - called on unmount, e.g. to dispose the remote handler
|
||||
* @returns {Promise<{ id: string, srUuid: string, vdiUuid: string, iqn: string, address: string, port: number }>}
|
||||
*/
|
||||
async mountDisk(params) {
|
||||
const mount = await this.#createDiskMount(params)
|
||||
this.#mounts.set(mount.id, mount)
|
||||
return {
|
||||
id: mount.id,
|
||||
srUuid: mount.srUuid,
|
||||
vdiUuid: mount.vdiUuid,
|
||||
iqn: mount.iqn,
|
||||
address: mount.address,
|
||||
port: mount.port,
|
||||
}
|
||||
}
|
||||
|
||||
#createDiskMount = defer(async ($defer, { handler, diskPath, xapi, hostRef, nameLabel, release }) => {
|
||||
const config = this.#app.config
|
||||
// `iscsi.advertisedAddress` overrides auto-detection; unset, the address
|
||||
// reachable *from* the target host is guessed by asking the OS which
|
||||
// local address it would route through to reach it — usually right, but
|
||||
// not guaranteed to be reachable *back* from the host (NAT, asymmetric
|
||||
// routing), which is what the override is for.
|
||||
let address = config.getOptional('iscsi.advertisedAddress')
|
||||
if (address === undefined) {
|
||||
const hostAddress = await xapi.getField('host', hostRef, 'address')
|
||||
address = await this.#detectAddress(hostAddress)
|
||||
}
|
||||
|
||||
const id = randomBytes(16).toString('hex')
|
||||
const iqn = `iqn.2026-07.tech.vates.xo:live-mount-${id}`
|
||||
const chap = createChapCredentials(id)
|
||||
|
||||
// the chain must keep its block allocation tables: they tell which blocks
|
||||
// are allocated, and reading an unallocated one throws
|
||||
const disk = await this.#openDisk({ handler, path: diskPath })
|
||||
$defer.onFailure(() => disk.close())
|
||||
|
||||
const lun = new DiskBlockDevice({ disk })
|
||||
const target = this.#createTarget({
|
||||
chap,
|
||||
host: config.getOptional('iscsi.bindAddress'),
|
||||
identity: { serial: `xo-live-mount-${id}` },
|
||||
iqn,
|
||||
lun,
|
||||
port: 0, // ephemeral: one target per mount
|
||||
})
|
||||
// opens the LUN, so its capacity is readable afterwards
|
||||
await target.listen()
|
||||
$defer.onFailure(() => target.close())
|
||||
const { port } = target.address()
|
||||
|
||||
const deviceConfig = {
|
||||
chapuser: chap.user,
|
||||
chappassword: chap.secret,
|
||||
port: String(port),
|
||||
target: address,
|
||||
targetIQN: iqn,
|
||||
}
|
||||
|
||||
const SCSIid = await probeScsiId({ xapi, hostRef, deviceConfig, address })
|
||||
const fullDeviceConfig = { ...deviceConfig, SCSIid }
|
||||
|
||||
const { srRef, srUuid } = await introduceSr($defer, {
|
||||
xapi,
|
||||
hostRef,
|
||||
deviceConfig: fullDeviceConfig,
|
||||
id,
|
||||
nameLabel,
|
||||
diskPath,
|
||||
})
|
||||
|
||||
const vdiUuid = await introduceVdi({ xapi, srRef, SCSIid, size: lun.getSize(), diskPath, readOnly: true })
|
||||
|
||||
info('mounted', { id, address, port, srUuid, vdiUuid, diskPath })
|
||||
|
||||
return { address, disk, diskPath, id, iqn, port, release, srRef, srUuid, target, vdiUuid, xapi }
|
||||
})
|
||||
|
||||
/**
|
||||
* Detach a mount from its host and stop serving it.
|
||||
*
|
||||
* Each teardown step runs even if an earlier one failed: a mount holds a
|
||||
* socket, a disk chain, a VDI and an SR, and giving up halfway would leak
|
||||
* whatever came after.
|
||||
*
|
||||
* @param {string} id - identifier returned by {@link LiveMount#mountDisk}
|
||||
*/
|
||||
async unmountDisk(id) {
|
||||
const mount = this.#mounts.get(id)
|
||||
if (mount === undefined) {
|
||||
throw new Error(`no such live mount ${id}`)
|
||||
}
|
||||
// drop it first, so a failing teardown cannot be retried against a
|
||||
// half-released mount
|
||||
this.#mounts.delete(id)
|
||||
|
||||
const { xapi, srRef, target, release } = mount
|
||||
|
||||
const errors = []
|
||||
const step = async (what, fn) => {
|
||||
try {
|
||||
await fn()
|
||||
} catch (error) {
|
||||
warn(`failed to ${what}`, { error, id })
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
|
||||
await step('forget the SR', () => forgetSr(xapi, srRef))
|
||||
// stop serving first, so no I/O is left in flight
|
||||
await step('close the target', () => target.close())
|
||||
await step('release the caller resources', () => release?.())
|
||||
|
||||
if (errors.length !== 0) {
|
||||
const error = new Error(`failed to unmount live mount ${id}`)
|
||||
error.cause = errors[0]
|
||||
error.errors = errors
|
||||
throw error
|
||||
}
|
||||
info('unmounted', { id, srUuid: mount.srUuid })
|
||||
}
|
||||
|
||||
/** Live disk mounts, in creation order. */
|
||||
listMountedDisks() {
|
||||
return [...this.#mounts.values()].map(({ id, srUuid, vdiUuid, diskPath, iqn, address, port }) => ({
|
||||
id,
|
||||
srUuid,
|
||||
vdiUuid,
|
||||
diskPath,
|
||||
iqn,
|
||||
address,
|
||||
port,
|
||||
}))
|
||||
}
|
||||
}
|
||||
322
@xen-orchestra/mixins/live-mount/index.test.mjs
Normal file
322
@xen-orchestra/mixins/live-mount/index.test.mjs
Normal file
@@ -0,0 +1,322 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import LiveMount from './index.mjs'
|
||||
|
||||
const SCSI_ID = '1VATES_xo-backup-db4582d0c901d31379ac5eb5deea9a68'
|
||||
const DISK_SIZE = 2 * 1024 * 1024 * 1024
|
||||
|
||||
// as reported by a real host, trailing space included
|
||||
const LUN_LIST_XML = `<?xml version="1.0" ?>
|
||||
<iscsi-target>
|
||||
<LUN>
|
||||
<vendor>VATES</vendor>
|
||||
<LUNid>0</LUNid>
|
||||
<size>2147483648</size>
|
||||
<SCSIid>${SCSI_ID} </SCSIid>
|
||||
</LUN>
|
||||
</iscsi-target>`
|
||||
|
||||
const HOST_REF = 'OpaqueRef:host'
|
||||
const SR_REF = 'OpaqueRef:sr'
|
||||
|
||||
class XapiError extends Error {
|
||||
constructor(code, params) {
|
||||
super(code)
|
||||
this.code = code
|
||||
this.params = params
|
||||
}
|
||||
}
|
||||
|
||||
const makeXapi = ({ probeError, vdiSmConfig } = {}) => {
|
||||
const calls = []
|
||||
// `call` and `callAsync` answer the same way: which one a method goes through
|
||||
// is xen-api's concern, the assertions below only care that it was called
|
||||
const handle = (method, ...args) => {
|
||||
calls.push([method, ...args])
|
||||
switch (method) {
|
||||
case 'SR.probe':
|
||||
throw probeError ?? new XapiError('SR_BACKEND_FAILURE_107', ['', '', LUN_LIST_XML])
|
||||
case 'SR.introduce':
|
||||
return SR_REF
|
||||
case 'PBD.create':
|
||||
return 'OpaqueRef:pbd'
|
||||
case 'SR.get_VDIs':
|
||||
return ['OpaqueRef:vdi']
|
||||
case 'SR.get_PBDs':
|
||||
return ['OpaqueRef:pbd']
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return {
|
||||
calls,
|
||||
async call(method, ...args) {
|
||||
return handle(method, ...args)
|
||||
},
|
||||
async callAsync(method, ...args) {
|
||||
return handle(method, ...args)
|
||||
},
|
||||
async setFieldEntry(...args) {
|
||||
calls.push(['setFieldEntry', ...args])
|
||||
},
|
||||
async getField(type, ref, field) {
|
||||
calls.push(['getField', type, ref, field])
|
||||
if (type === 'host' && field === 'address') {
|
||||
return '10.20.30.40' // the host's own management address, as XAPI reports it
|
||||
}
|
||||
return 'sr-uuid'
|
||||
},
|
||||
async getRecord(type, ref) {
|
||||
calls.push(['getRecord', type, ref])
|
||||
// the driver derives the VDI uuid from the LUN serial, so it differs from
|
||||
// the one we asked for
|
||||
return { uuid: 'vdi-uuid', sm_config: vdiSmConfig ?? { SCSIid: SCSI_ID } }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const makeMixin = ({ diskOpenError, listenError, advertisedAddress = '192.168.1.8' } = {}) => {
|
||||
const hooks = new EventEmitter()
|
||||
const detectAddressCalls = []
|
||||
const app = {
|
||||
config: {
|
||||
getOptional: path => {
|
||||
if (path === 'iscsi.advertisedAddress') {
|
||||
// `null` (as opposed to the default) simulates an unset config key
|
||||
return advertisedAddress === null ? undefined : advertisedAddress
|
||||
}
|
||||
assert.equal(path, 'iscsi.bindAddress')
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
hooks,
|
||||
}
|
||||
|
||||
const disk = {
|
||||
closed: false,
|
||||
close: async () => (disk.closed = true),
|
||||
getBlockSize: () => 2 * 1024 * 1024, // as a VHD chain would report
|
||||
getVirtualSize: () => DISK_SIZE,
|
||||
}
|
||||
const target = {
|
||||
closed: false,
|
||||
options: undefined,
|
||||
address: () => ({ port: 34567 }),
|
||||
listen: async () => {
|
||||
if (listenError !== undefined) {
|
||||
throw listenError
|
||||
}
|
||||
// the real target opens the LUN, which is what exposes its capacity
|
||||
await target.options.lun.open()
|
||||
},
|
||||
close: async () => (target.closed = true),
|
||||
}
|
||||
|
||||
const mixin = new LiveMount(app, {
|
||||
openDisk: async params => {
|
||||
if (diskOpenError !== undefined) {
|
||||
throw diskOpenError
|
||||
}
|
||||
disk.params = params
|
||||
return disk
|
||||
},
|
||||
createTarget: options => {
|
||||
target.options = options
|
||||
return target
|
||||
},
|
||||
detectAddress: async hostAddress => {
|
||||
detectAddressCalls.push(hostAddress)
|
||||
return '203.0.113.7'
|
||||
},
|
||||
})
|
||||
|
||||
return { app, detectAddressCalls, disk, hooks, mixin, target }
|
||||
}
|
||||
|
||||
const mountDisk = (mixin, xapi, params) =>
|
||||
mixin.mountDisk({
|
||||
diskPath: 'xo-vm-backups/vm/vdis/job/vdi/20260731T120000Z.vhd',
|
||||
hostRef: HOST_REF,
|
||||
xapi,
|
||||
...params,
|
||||
})
|
||||
|
||||
describe('mountDisk', () => {
|
||||
it('serves the chain and attaches it as a non-shared iscsi SR on the host', async () => {
|
||||
const { mixin, target, disk } = makeMixin()
|
||||
const xapi = makeXapi()
|
||||
|
||||
const result = await mountDisk(mixin, xapi)
|
||||
|
||||
// the SR uuid is ours, since we introduce the SR instead of creating it
|
||||
assert.equal(result.srUuid, xapi.calls.find(([method]) => method === 'SR.introduce')[1])
|
||||
assert.match(result.srUuid, /^[0-9a-f-]{36}$/)
|
||||
assert.equal(result.vdiUuid, 'vdi-uuid')
|
||||
assert.equal(result.address, '192.168.1.8')
|
||||
assert.equal(result.port, 34567)
|
||||
assert.match(result.iqn, /^iqn\.2026-07\.tech\.vates\.xo:live-mount-[0-9a-f]{32}$/)
|
||||
|
||||
// the chain is opened with its block allocation tables
|
||||
assert.equal(disk.params.path, 'xo-vm-backups/vm/vdis/job/vdi/20260731T120000Z.vhd')
|
||||
assert.equal(disk.params.ignoreBlockIndexes, undefined)
|
||||
|
||||
// one ephemeral target per mount, CHAP enabled, unique serial
|
||||
assert.equal(target.options.port, 0)
|
||||
assert.equal(target.options.iqn, result.iqn)
|
||||
assert.equal(target.options.chap.secret.length, 16)
|
||||
assert.equal(target.options.identity.serial, `xo-live-mount-${result.id}`)
|
||||
|
||||
// introduced, not created: SR.create would scan and introduce the VDI itself
|
||||
const srIntroduce = xapi.calls.find(([method]) => method === 'SR.introduce')
|
||||
assert.equal(srIntroduce[4], 'iscsi') // type
|
||||
assert.equal(srIntroduce[6], false) // shared
|
||||
assert.ok(!xapi.calls.some(([method]) => method === 'SR.create'))
|
||||
|
||||
// the PBD is created on the requested host only
|
||||
const pbdCreate = xapi.calls.find(([method]) => method === 'PBD.create')[1]
|
||||
assert.equal(pbdCreate.host, HOST_REF)
|
||||
assert.equal(pbdCreate.SR, SR_REF)
|
||||
assert.deepEqual(pbdCreate.device_config, {
|
||||
SCSIid: SCSI_ID,
|
||||
chapuser: target.options.chap.user,
|
||||
chappassword: target.options.chap.secret,
|
||||
port: '34567',
|
||||
target: '192.168.1.8',
|
||||
targetIQN: result.iqn,
|
||||
})
|
||||
assert.ok(xapi.calls.some(([method, ref]) => method === 'PBD.plug' && ref === 'OpaqueRef:pbd'))
|
||||
|
||||
// the VDI is introduced by us: read_only and sm_config are settable only here
|
||||
const vdiIntroduce = xapi.calls.find(([method]) => method === 'VDI.introduce')
|
||||
assert.equal(vdiIntroduce[4], SR_REF)
|
||||
assert.equal(vdiIntroduce[5], 'user')
|
||||
assert.equal(vdiIntroduce[7], true) // read_only
|
||||
// `type` is the legacy key the sm drivers still accept:
|
||||
// `vdi_sm_config.get("image-format") or vdi_sm_config.get("type")`
|
||||
assert.deepEqual(vdiIntroduce[11], { LUNid: '0', SCSIid: SCSI_ID, type: 'raw' })
|
||||
assert.equal(vdiIntroduce[12], true) // managed
|
||||
assert.equal(vdiIntroduce[13], DISK_SIZE) // virtual size, from the LUN
|
||||
|
||||
// probe uses lvmoiscsi and does not name a SCSIid yet
|
||||
const probe = xapi.calls.find(([method]) => method === 'SR.probe')
|
||||
assert.equal(probe[3], 'lvmoiscsi')
|
||||
assert.equal(probe[2].SCSIid, undefined)
|
||||
|
||||
// ephemeral SR: tagged and not scanned at boot
|
||||
assert.deepEqual(
|
||||
xapi.calls.filter(([method]) => method === 'setFieldEntry').map(([, , , , entry, value]) => [entry, value]),
|
||||
[
|
||||
['xo:live-mount', result.id],
|
||||
['auto-scan', 'false'],
|
||||
]
|
||||
)
|
||||
// VDI.read_only is StaticRO in XAPI: nothing may try to set it
|
||||
assert.ok(!xapi.calls.some(([method]) => method === 'VDI.set_read_only'))
|
||||
|
||||
assert.deepEqual(
|
||||
mixin.listMountedDisks().map(({ id, srUuid, vdiUuid }) => ({ id, srUuid, vdiUuid })),
|
||||
[{ id: result.id, srUuid: result.srUuid, vdiUuid: 'vdi-uuid' }]
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an unreachable target as a configuration problem', async () => {
|
||||
const { mixin } = makeMixin()
|
||||
const xapi = makeXapi({ probeError: new XapiError('SR_BACKEND_FAILURE_141', []) })
|
||||
|
||||
await assert.rejects(mountDisk(mixin, xapi), /cannot reach the iSCSI target at 192\.168\.1\.8/)
|
||||
})
|
||||
|
||||
it('closes the target and the disk when the probe fails', async () => {
|
||||
const { mixin, target, disk } = makeMixin()
|
||||
const xapi = makeXapi({ probeError: new XapiError('SR_BACKEND_FAILURE_666', []) })
|
||||
|
||||
await assert.rejects(mountDisk(mixin, xapi), { code: 'SR_BACKEND_FAILURE_666' })
|
||||
|
||||
assert.equal(target.closed, true)
|
||||
assert.equal(disk.closed, true)
|
||||
assert.deepEqual(mixin.listMountedDisks(), [])
|
||||
})
|
||||
|
||||
it('closes the disk when the target cannot listen', async () => {
|
||||
const { mixin, disk } = makeMixin({ listenError: new Error('EADDRINUSE') })
|
||||
|
||||
await assert.rejects(mountDisk(mixin, makeXapi()), /EADDRINUSE/)
|
||||
|
||||
assert.equal(disk.closed, true)
|
||||
})
|
||||
|
||||
it('auto-detects the address when iscsi.advertisedAddress is not configured', async () => {
|
||||
const { mixin, detectAddressCalls } = makeMixin({ advertisedAddress: null })
|
||||
const xapi = makeXapi()
|
||||
|
||||
const result = await mountDisk(mixin, xapi)
|
||||
|
||||
assert.equal(result.address, '203.0.113.7')
|
||||
// detected by routing towards the target host's own address, not guessed blindly
|
||||
assert.deepEqual(detectAddressCalls, ['10.20.30.40'])
|
||||
assert.ok(
|
||||
xapi.calls.some(([method, , ref, field]) => method === 'getField' && ref === HOST_REF && field === 'address')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmountDisk', () => {
|
||||
it('unplugs, forgets, closes the target and releases the caller resources', async () => {
|
||||
const { mixin, target } = makeMixin()
|
||||
const xapi = makeXapi()
|
||||
let released = false
|
||||
|
||||
const { id } = await mountDisk(mixin, xapi, { release: async () => (released = true) })
|
||||
xapi.calls.length = 0
|
||||
|
||||
await mixin.unmountDisk(id)
|
||||
|
||||
assert.deepEqual(
|
||||
xapi.calls.map(([method]) => method),
|
||||
['SR.get_PBDs', 'PBD.unplug', 'SR.forget']
|
||||
)
|
||||
assert.equal(target.closed, true)
|
||||
assert.equal(released, true)
|
||||
assert.deepEqual(mixin.listMountedDisks(), [])
|
||||
})
|
||||
|
||||
it('still closes the target and releases resources when forgetting the SR fails', async () => {
|
||||
const { mixin, target } = makeMixin()
|
||||
const xapi = makeXapi()
|
||||
let released = false
|
||||
const { id } = await mountDisk(mixin, xapi, { release: async () => (released = true) })
|
||||
xapi.call = xapi.callAsync = async () => {
|
||||
throw new Error('SR_HAS_NO_PBDS')
|
||||
}
|
||||
|
||||
await assert.rejects(mixin.unmountDisk(id), error => {
|
||||
assert.match(error.message, /failed to unmount live mount/)
|
||||
assert.equal(error.cause.message, 'SR_HAS_NO_PBDS')
|
||||
return true
|
||||
})
|
||||
|
||||
assert.equal(target.closed, true)
|
||||
assert.equal(released, true)
|
||||
// the mount is gone either way, a half-released mount must not be retried
|
||||
assert.deepEqual(mixin.listMountedDisks(), [])
|
||||
})
|
||||
|
||||
it('rejects an unknown mount', async () => {
|
||||
const { mixin } = makeMixin()
|
||||
await assert.rejects(mixin.unmountDisk('nope'), /no such live mount nope/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the stop hook', () => {
|
||||
it('tears down every live mount', async () => {
|
||||
const { mixin, hooks, target } = makeMixin()
|
||||
await mountDisk(mixin, makeXapi())
|
||||
|
||||
await Promise.all(hooks.listeners('stop').map(listener => listener()))
|
||||
|
||||
assert.equal(target.closed, true)
|
||||
assert.deepEqual(mixin.listMountedDisks(), [])
|
||||
})
|
||||
})
|
||||
@@ -19,12 +19,17 @@
|
||||
"node": ">=15.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@vates/event-listeners-manager": "^1.0.1",
|
||||
"@vates/iscsi": "^0.1.0",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@vates/task": "^0.7.0",
|
||||
"@xen-orchestra/backup-archive": "^1.1.1",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"acme-client": "^5.0.0",
|
||||
"app-conf": "^3.0.0",
|
||||
"fast-xml-parser": "^4.0.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"lodash": "^4.18.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"xo-common": "^0.11.0"
|
||||
|
||||
@@ -82,12 +82,14 @@
|
||||
|
||||
<!--packages-start-->
|
||||
|
||||
- @vates/iscsi minor
|
||||
- @vates/node-vsphere-soap minor
|
||||
- @vates/types minor
|
||||
- @xen-orchestra/backup-archive patch
|
||||
- @xen-orchestra/backups patch
|
||||
- @xen-orchestra/disk-cli patch
|
||||
- @xen-orchestra/fs patch
|
||||
- @xen-orchestra/mixins minor
|
||||
- @xen-orchestra/proxy minor
|
||||
- @xen-orchestra/qcow2 minor
|
||||
- @xen-orchestra/rest-api minor
|
||||
|
||||
@@ -205,6 +205,20 @@ requestTimeout = 0
|
||||
'/v5/netdata' = '[protocol]//localhost:[port]/netdata'
|
||||
'/v5/rest' = '[protocol]//localhost:[port]/rest'
|
||||
|
||||
[iscsi]
|
||||
# Address this appliance is reachable at *from the hosts*: it is handed to them
|
||||
# as the iSCSI portal when a backup disk is 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 appliance 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:load-balancer'
|
||||
|
||||
126
packages/xo-server/src/xo-mixins/backup-disk-mounts.mjs
Normal file
126
packages/xo-server/src/xo-mixins/backup-disk-mounts.mjs
Normal file
@@ -0,0 +1,126 @@
|
||||
import { invalidParameters, noSuchObject } from 'xo-common/api-errors.js'
|
||||
|
||||
/**
|
||||
* @typedef {import('@vates/types').XoApp} XoApp
|
||||
* @typedef {import('@vates/types').BackupArchiveDiskMount} BackupArchiveDiskMount
|
||||
* @typedef {import('@vates/types').XoBackupRepository} XoBackupRepository
|
||||
* @typedef {import('@vates/types').XoHost} XoHost
|
||||
* @typedef {import('@vates/types').XoVmBackupArchive} XoVmBackupArchive
|
||||
*/
|
||||
|
||||
/**
|
||||
* a backup archive id is `<backup repository id>/<metadata path>`
|
||||
*
|
||||
* @param {XoVmBackupArchive['id']} archiveId
|
||||
* @returns {XoBackupRepository['id']}
|
||||
*/
|
||||
const getBackupRepositoryId = archiveId => archiveId.split('/')[0]
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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()
|
||||
|
||||
/** @param {XoApp} app */
|
||||
constructor(app) {
|
||||
this.#app = app
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve one disk of a backup archive as a read-only iSCSI LUN and attach it to
|
||||
* a host as an SR.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {XoVmBackupArchive['id']} params.archiveId - `<backup repository id>/<metadata path>`
|
||||
* @param {string} params.diskId - id of one of the archive's disks, a path on the backup repository
|
||||
* @param {XoHost['id']} params.hostId - id of the host the disk is attached to
|
||||
* @returns {Promise<BackupArchiveDiskMount>}
|
||||
*/
|
||||
async mountBackupArchiveDisk({ archiveId, diskId, hostId }) {
|
||||
const app = this.#app
|
||||
|
||||
const archive = await this.#getArchive(archiveId)
|
||||
if (!archive.disks.some(disk => disk.id === diskId)) {
|
||||
// `diskId` is a path on the backup repository, an unchecked one would
|
||||
// expose any file it contains
|
||||
throw invalidParameters(`disk ${diskId} does not belong to backup archive ${archiveId}`)
|
||||
}
|
||||
|
||||
const host = app.getObject(hostId, 'host')
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive/host a mount actually belongs to, so callers (e.g. the REST API's
|
||||
* 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'] }}
|
||||
*/
|
||||
getBackupArchiveDiskMountOwner(id) {
|
||||
const owner = this.#mountOwners.get(id)
|
||||
if (owner === undefined) {
|
||||
throw noSuchObject(id, 'backup-archive-disk-mount')
|
||||
}
|
||||
return owner
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BackupArchiveDiskMount['id']} id - identifier returned by `mountBackupArchiveDisk`
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
unmountBackupArchiveDisk(id) {
|
||||
this.#mountOwners.delete(id)
|
||||
return this.#app.liveMount.unmountDisk(id)
|
||||
}
|
||||
|
||||
/** @returns {import('@vates/types').MountedBackupArchiveDisk[]} */
|
||||
listMountedBackupArchiveDisks() {
|
||||
return this.#app.liveMount.listMountedDisks()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {XoVmBackupArchive['id']} archiveId
|
||||
* @returns {Promise<XoVmBackupArchive>}
|
||||
*/
|
||||
async #getArchive(archiveId) {
|
||||
const backupRepositoryId = getBackupRepositoryId(archiveId)
|
||||
const backupsByVm = (await this.#app.listVmBackupsNg([backupRepositoryId]))[backupRepositoryId] ?? {}
|
||||
for (const backups of Object.values(backupsByVm)) {
|
||||
const archive = backups.find(backup => backup.id === archiveId)
|
||||
if (archive !== undefined) {
|
||||
return archive
|
||||
}
|
||||
}
|
||||
throw noSuchObject(archiveId, 'backup-archive')
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import HttpProxy from '@xen-orchestra/mixins/HttpProxy.mjs'
|
||||
import includes from 'lodash/includes.js'
|
||||
import isEmpty from 'lodash/isEmpty.js'
|
||||
import iteratee from 'lodash/iteratee.js'
|
||||
import LiveMount from '@xen-orchestra/mixins/live-mount/index.mjs'
|
||||
import mixin from '@xen-orchestra/mixin'
|
||||
import mixinLegacy from '@xen-orchestra/mixin/legacy.js'
|
||||
import once from 'lodash/once.js'
|
||||
@@ -39,7 +40,7 @@ export default class Xo extends EventEmitter {
|
||||
constructor(opts) {
|
||||
super()
|
||||
|
||||
mixin(this, { Config, Hooks, HttpProxy, SslCertificate, Tasks }, [opts])
|
||||
mixin(this, { Config, Hooks, HttpProxy, LiveMount, SslCertificate, Tasks }, [opts])
|
||||
// a lot of mixins adds listener for start/stop/… events
|
||||
this.hooks.setMaxListeners(0)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user