Compare commits

...

10 Commits

Author SHA1 Message Date
Florent BEAUCHAMP
0aa29f4817 fix(xo-server): type the backup disk mounts mixin with @vates/types 2026-09-17 15:24:08 +02:00
Florent BEAUCHAMP
2393c146f0 fix(@xen-orchestra/mixins): use callAsync to plug and unplug the PBD 2026-09-17 15:24:08 +02:00
Florent BEAUCHAMP
87c6a19fa5 fix(@vates/iscsi): make the package publishable
@xen-orchestra/mixins is public and depends on it, so leaving it private
makes the published mixins package uninstallable.
2026-09-17 15:24:08 +02:00
Florent BEAUCHAMP
cf8aaeb857 throw an error if vdi is not present in virtual SR 2026-09-17 15:24:08 +02:00
Florent BEAUCHAMP
31a0252228 fix(xo-server): better tracking of the owner of a mount 2026-09-17 15:24:08 +02:00
Florent BEAUCHAMP
3ada19bb2c chore: list @xen-orchestra/mixins in the packages to release 2026-09-17 15:23:37 +02:00
Florent BEAUCHAMP
132a4e28bc feat: autodetect a possible XOA ip if possible 2026-09-17 15:23:26 +02:00
Florent BEAUCHAMP
5442b22dbc feat(xo-server): mount a backup archive disk into a read-only SR
Resolution layer turning a backup archive id + disk id + host id into what
LiveMount needs (remote handler, disk path, XAPI connection), plus the
iscsi.advertisedAddress/bindAddress config keys. Internal app methods only —
no REST endpoint yet.
2026-09-17 15:23:26 +02:00
Florent BEAUCHAMP
a229360f4b feat(@vates/types): describe the backup-archive live-mount types 2026-09-17 15:23:26 +02:00
Florent BEAUCHAMP
e385ec20fa feat(xo-mixins): add LiveMount, serving a disk as a read-only iSCSI LUN
Generic mixin, shared by xo-server and xo-proxy: given a XAPI connection, a
target host, and any source disk the caller knows how to open, it serves that
disk read-only over iSCSI (@vates/iscsi's DiskBlockDevice, no cache) and
attaches it to the host as a non-shared 'iscsi' SR + VDI.

Not wired into anything yet — xo-server integration follows.
2026-09-17 15:12:22 +02:00
14 changed files with 957 additions and 2 deletions

View File

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

View File

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

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

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

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

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

View 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$/, '')

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

View 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(), [])
})
})

View File

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

View File

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

View File

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

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

View File

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