diff --git a/@vates/types/package.json b/@vates/types/package.json index f843104652..90bdb353ce 100644 --- a/@vates/types/package.json +++ b/@vates/types/package.json @@ -33,7 +33,8 @@ "@eslint/js": "^9.19.0", "typescript": "~5.6.3", "typescript-eslint": "^8.61.0", - "@types/express": "^5.0.0" + "@types/express": "^5.0.0", + "@xen-orchestra/disk-transform": "^1.3.3" }, "scripts": { "build": "tsc", diff --git a/@vates/types/src/lib/xen-orchestra-xapi.mts b/@vates/types/src/lib/xen-orchestra-xapi.mts index cb86b8bcfc..47a62071e6 100644 --- a/@vates/types/src/lib/xen-orchestra-xapi.mts +++ b/@vates/types/src/lib/xen-orchestra-xapi.mts @@ -24,6 +24,7 @@ import type { VIF_LOCKING_MODE, VM_OPERATIONS, } from '../common.mjs' +import type { DiskPassthrough } from '@xen-orchestra/disk-transform' import type { PassThrough, Readable } from 'node:stream' import type { XapiXoRecord, @@ -476,3 +477,42 @@ export interface Xapi { } } } + +/** + * Parameters accepted by the `XapiDiskSource` constructor. + * + * `baseRef` turns the export into a differencing one: leaving it out exports the whole disk. + * `preferNbd` only expresses a preference, {@link XapiDiskSource} silently falls back to a plain + * stream export — and, when `baseRef` can't be used as a base, to a full one. + */ +export interface XapiDiskSourceOptions { + baseRef?: XenApiVdi['$ref'] + blockSize?: number + nbdConcurrency?: number + /** When true, only `getBlockIndexes()` may be called: `readBlock()` throws. */ + onlyListChangedBlocks?: boolean + preferNbd?: boolean + timeout?: number + vdiRef: XenApiVdi['$ref'] + xapi: Xapi +} + +/** + * Disk source handling the fallback logic of a VDI export: NBD + CBT, then NBD + stream export + * for the block list, then plain stream export. + * + * {@link XapiDiskSourceOptions.preferNbd} is a preference, not a guarantee: call + * {@link XapiDiskSource.useNbd} and {@link XapiDiskSource.useCbt} after `init()` to know what the + * export actually used. + */ +export interface XapiDiskSource extends DiskPassthrough { + useCbt(): boolean + useNbd(): boolean +} + +/** + * Signature of `@xen-orchestra/xapi`'s `parseDateTime`. + * + * Returns a Unix timestamp in seconds, or `null` if the field is empty (as encoded by XAPI). + */ +export type ParseDateTime = (input: string | number | Date) => number | null diff --git a/@vates/types/tsconfig.json b/@vates/types/tsconfig.json index 19e37aade2..a40d951c42 100644 --- a/@vates/types/tsconfig.json +++ b/@vates/types/tsconfig.json @@ -4,6 +4,7 @@ "declaration": true, "strict": true, "module": "ESNext", + "moduleResolution": "bundler", "target": "ES2022", "skipLibCheck": true, "types": ["node"] // Only use types from Node.js and packages that are explicitly imported diff --git a/@xen-orchestra/qcow2/src/consumer/ConsumerQcowStream.mts b/@xen-orchestra/qcow2/src/consumer/ConsumerQcowStream.mts index 1ff008aa21..bb7f47a018 100644 --- a/@xen-orchestra/qcow2/src/consumer/ConsumerQcowStream.mts +++ b/@xen-orchestra/qcow2/src/consumer/ConsumerQcowStream.mts @@ -23,7 +23,7 @@ function getAlignedBuffer(length: number): Buffer { /** * Extended Readable stream type that may include a length property */ -type WithLength = T & { length?: number } +export type WithLength = T & { length?: number } /** * Generates a valid QCOW2 stream from a Disk. @@ -373,7 +373,7 @@ export class QcowStreamGenerator { * @param disk The disk to convert * @param options Optional options * @param options.signal Optional AbortSignal to cancel the stream - * @returns Readable stream of QCOW2 data + * @returns Readable stream of QCOW2 data, `length` is the exact size of the generated file */ export async function toQcow2Stream( disk: Disk, diff --git a/@xen-orchestra/qcow2/src/index.mts b/@xen-orchestra/qcow2/src/index.mts index 1acc96d15a..46dbbd1fdb 100644 --- a/@xen-orchestra/qcow2/src/index.mts +++ b/@xen-orchestra/qcow2/src/index.mts @@ -1,3 +1,3 @@ export { QcowStream } from './disk/QcowStream.mjs' export { QCowAccessor } from './disk/QcowAccessor.mjs' -export { QcowStreamGenerator, toQcow2Stream } from './consumer/ConsumerQcowStream.mjs' +export { QcowStreamGenerator, toQcow2Stream, type WithLength } from './consumer/ConsumerQcowStream.mjs' diff --git a/@xen-orchestra/rest-api/package.json b/@xen-orchestra/rest-api/package.json index b76ef6fca7..7da910f70d 100644 --- a/@xen-orchestra/rest-api/package.json +++ b/@xen-orchestra/rest-api/package.json @@ -28,6 +28,7 @@ "@eslint/js": "^9.19.0", "@types/express": "^5.0.0", "@types/swagger-ui-express": "^4.1.7", + "@xen-orchestra/disk-transform": "^1.3.3", "openapi-types": "^12.1.3", "rimraf": "^6.0.1", "typescript": "~5.6.3", @@ -40,6 +41,7 @@ "@xen-orchestra/acl": "^1.6.1", "@xen-orchestra/backups": "^0.74.2", "@xen-orchestra/log": "^0.7.2", + "@xen-orchestra/qcow2": "^1.3.1", "@xen-orchestra/xapi": "^8.10.5", "complex-matcher": "^1.1.1", "golike-defer": "^0.5.1", @@ -51,6 +53,7 @@ "swagger-ui-express": "^5.0.1", "tsoa": "^6.6.0", "value-matcher": "^0.2.0", + "vhd-lib": "^4.16.1", "xo-common": "^0.11.0", "xo-remote-parser": "^0.10.1", "zod": "^4.3.6", diff --git a/@xen-orchestra/rest-api/src/lib-types/vhd-lib.d.ts b/@xen-orchestra/rest-api/src/lib-types/vhd-lib.d.ts new file mode 100644 index 0000000000..c32617eb8b --- /dev/null +++ b/@xen-orchestra/rest-api/src/lib-types/vhd-lib.d.ts @@ -0,0 +1,27 @@ +// `vhd-lib` is plain JS without published typings: this only binds the names used by the REST API +// +// @todo move these types to `@vates/types` so `@xen-orchestra/disk-cli`, which declares the same +// module on its own, shares them +declare module 'vhd-lib/disk-consumer/index.mjs' { + import type { Disk } from '@xen-orchestra/disk-transform' + import type { Readable } from 'node:stream' + + /** + * `parentUuid` and `parentPath` describe the parent of a differencing VHD and must be given + * together, `uuid` identifies the produced VHD itself. + */ + export interface ToVhdStreamOptions { + parentPath?: string + parentUuid?: Buffer + signal?: AbortSignal + uuid?: Buffer + } + + /** + * The `length` of the returned stream is the exact size of the resulting VHD file. + * + * It is set by `DiskConsumerVhdStream` once the geometry is known, before the first byte is + * emitted, so it can be used as a `Content-Length`. + */ + export function toVhdStream(disk: Disk, options?: ToVhdStreamOptions): Promise +} diff --git a/@xen-orchestra/rest-api/src/lib-types/xen-orchestra-xapi.d.ts b/@xen-orchestra/rest-api/src/lib-types/xen-orchestra-xapi.d.ts new file mode 100644 index 0000000000..92de3563fe --- /dev/null +++ b/@xen-orchestra/rest-api/src/lib-types/xen-orchestra-xapi.d.ts @@ -0,0 +1,17 @@ +// `@xen-orchestra/xapi` is plain JS without published typings: this only binds the names used by +// the REST API to the shapes declared in `@vates/types` +declare module '@xen-orchestra/xapi' { + import type { ParseDateTime, XapiDiskSource, XapiDiskSourceOptions } from '@vates/types' + + /** + * Biggest disk size that can be represented in a VHD file. + * + * Note: nothing in `vhd-lib` rejects a bigger disk, the geometry is silently + * clamped instead, therefore callers must check this limit themselves. + */ + export const VHD_MAX_SIZE: number + + export const parseDateTime: ParseDateTime + + export const XapiDiskSource: new (params: XapiDiskSourceOptions) => XapiDiskSource +} diff --git a/@xen-orchestra/rest-api/src/vdi-snapshots/vdi-snapshot.controller.mts b/@xen-orchestra/rest-api/src/vdi-snapshots/vdi-snapshot.controller.mts index 969deed9c0..0aabfdaa6e 100644 --- a/@xen-orchestra/rest-api/src/vdi-snapshots/vdi-snapshot.controller.mts +++ b/@xen-orchestra/rest-api/src/vdi-snapshots/vdi-snapshot.controller.mts @@ -17,7 +17,7 @@ import { import { inject } from 'inversify' import { provide } from 'inversify-binding-decorators' import type { Readable } from 'node:stream' -import type { Request as ExRequest, Response as ExResponse } from 'express' +import type { Request as ExRequest } from 'express' import type { SUPPORTED_VDI_FORMAT, XoAlarm, XoMessage, XoTask, XoVdiSnapshot } from '@vates/types' import { acl } from '../middlewares/acl.middleware.mjs' @@ -104,13 +104,17 @@ export class VdiSnapshotController extends XapiXoController { async exportVdiSnapshotContent( @Request() req: ExRequest, @Path() id: string, - @Path() format: Exclude + @Path() format: SUPPORTED_VDI_FORMAT ): Promise { - const res = req.res as ExResponse - const stream = await this.#vdiService.exportContent(id as XoVdiSnapshot['id'], 'VDI-snapshot', { - format, - response: res, - }) + const stream = await this.#vdiService.exportContent(id as XoVdiSnapshot['id'], 'VDI-snapshot', { format }) + + this.setHeader('content-disposition', `attachment; filename=${id}.${format}`) + this.setHeader('content-type', 'application/octet-stream') + if (stream.length !== undefined) { + // the size of an export is always known in advance, whatever its format + this.setHeader('content-length', String(stream.length)) + } + process.on('SIGTERM', () => req.destroy()) req.on('close', () => stream.destroy()) return stream diff --git a/@xen-orchestra/rest-api/src/vdis/vdi.controller.mts b/@xen-orchestra/rest-api/src/vdis/vdi.controller.mts index 829c779f7e..f6d6564a6e 100644 --- a/@xen-orchestra/rest-api/src/vdis/vdi.controller.mts +++ b/@xen-orchestra/rest-api/src/vdis/vdi.controller.mts @@ -20,7 +20,7 @@ import { import { inject } from 'inversify' import { provide } from 'inversify-binding-decorators' import type { Readable } from 'node:stream' -import { json, type Request as ExRequest, type Response as ExResponse } from 'express' +import { json, type Request as ExRequest } from 'express' import type { SUPPORTED_VDI_FORMAT, Xapi, XoAlarm, XoMessage, XoSr, XoTask, XoVdi } from '@vates/types' import { SUPPORTED_ACTIONS_BY_RESOURCE, type SupportedActions } from '@xen-orchestra/acl' @@ -133,10 +133,17 @@ export class VdiController extends XapiXoController { async exportVdiContent( @Request() req: ExRequest, @Path() id: string, - @Path() format: Exclude + @Path() format: SUPPORTED_VDI_FORMAT ): Promise { - const res = req.res as ExResponse - const stream = await this.#vdiService.exportContent(id as XoVdi['id'], 'VDI', { format, response: res }) + const stream = await this.#vdiService.exportContent(id as XoVdi['id'], 'VDI', { format }) + + this.setHeader('content-disposition', `attachment; filename=${id}.${format}`) + this.setHeader('content-type', 'application/octet-stream') + if (stream.length !== undefined) { + // the size of an export is always known in advance, whatever its format + this.setHeader('content-length', String(stream.length)) + } + process.on('SIGTERM', () => req.destroy()) req.on('close', () => stream.destroy()) return stream diff --git a/@xen-orchestra/rest-api/src/vdis/vdi.service.mts b/@xen-orchestra/rest-api/src/vdis/vdi.service.mts index 6d53cd3801..12888b0abd 100644 --- a/@xen-orchestra/rest-api/src/vdis/vdi.service.mts +++ b/@xen-orchestra/rest-api/src/vdis/vdi.service.mts @@ -1,7 +1,19 @@ -import type { SUPPORTED_VDI_FORMAT, XoVdi, XoVdiSnapshot } from '@vates/types' +import { createLogger } from '@xen-orchestra/log' +import { toQcow2Stream } from '@xen-orchestra/qcow2' +import { SUPPORTED_VDI_FORMAT } from '@vates/types' +import { toVhdStream } from 'vhd-lib/disk-consumer/index.mjs' +import { VHD_MAX_SIZE, XapiDiskSource } from '@xen-orchestra/xapi' +import type { Disk } from '@xen-orchestra/disk-transform' +import type { XoVdi, XoVdiSnapshot } from '@vates/types' import type { Readable } from 'node:stream' + +import { ApiError } from '../helpers/error.helper.mjs' +import type { MaybePromise } from '../helpers/helper.type.mjs' import type { RestApi } from '../rest-api/rest-api.mjs' -import type { Response as ExResponse } from 'express' + +const log = createLogger('xo:rest-api:vdi-service') + +type ExportedContentStream = Readable & { length?: number } export class VdiService { #restApi: RestApi @@ -10,27 +22,80 @@ export class VdiService { this.#restApi = restApi } - async exportContent( + /** + * Open a `XapiDiskSource` on the VDI and let `toStream` rebuild the wanted format + * from its blocks, instead of relying on the export of the XAPI: this uses NBD when + * it is available, whatever the format the VDI is stored in, and gives the exact + * size of the export in advance. + */ + async #exportContentFromDiskSource( id: Vdi['id'], type: Vdi['type'], - { format, response }: { format: SUPPORTED_VDI_FORMAT; response?: ExResponse } - ): Promise { - const xapiVdi = this.#restApi.getXapiObject(id, type) - const stream = await xapiVdi.$xapi.VDI_exportContent(xapiVdi.$ref, { format }) + toStream: (disk: Disk) => MaybePromise + ): Promise { + const { $ref: vdiRef, $xapi: xapi } = this.#restApi.getXapiObject(id, type) + const disk = new XapiDiskSource({ xapi, vdiRef }) + await disk.init() - if (response !== undefined) { - const headers = new Headers({ - 'content-disposition': `attachment; filename=${id}.${format}`, - 'content-type': 'application/octet-stream', - }) + const closeDisk = () => + disk.close().catch(error => log.warn('failed to close the disk source', { error, vdiId: id })) - if (stream.length !== undefined) { - headers.append('content-length', stream.length.toString()) - } + try { + const stream = await toStream(disk) - response.setHeaders(headers) + // the block generator of the disk closes it when it ends, but it is never + // started if the stream is destroyed before being consumed + stream.once('close', closeDisk) + + return stream + } catch (error) { + await closeDisk() + throw error } + } + + /** + * A raw export is a byte for byte copy of the disk: its size is the virtual size of + * the VDI, even though the XAPI does not announce it. + */ + async #exportContentAsRaw( + id: Vdi['id'], + type: Vdi['type'] + ): Promise { + // `virtual_size` is read from the XAPI record, which is fetched anyway to get the + // ref: no extra object lookup, and no `VDI.get_virtual_size` call + const { $ref: vdiRef, $xapi: xapi, virtual_size: size } = this.#restApi.getXapiObject(id, type) + + const stream = await xapi.VDI_exportContent(vdiRef, { format: SUPPORTED_VDI_FORMAT.raw }) + stream.length = size return stream } + + /** + * `length` is set on the returned stream: the size of the export is always known in + * advance, it's up to the caller to expose it as a `content-length`. + */ + async exportContent( + id: Vdi['id'], + type: Vdi['type'], + { format }: { format: SUPPORTED_VDI_FORMAT } + ): Promise { + if (format === SUPPORTED_VDI_FORMAT.vhd) { + const { size } = this.#restApi.getObject(id, type) + if (size > VHD_MAX_SIZE) { + throw new ApiError(`a VDI of ${size} bytes is too large to be exported as VHD`, 422, { + data: { maxSize: VHD_MAX_SIZE, size }, + }) + } + + return this.#exportContentFromDiskSource(id, type, disk => toVhdStream(disk)) + } + + if (format === SUPPORTED_VDI_FORMAT.qcow2) { + return this.#exportContentFromDiskSource(id, type, disk => toQcow2Stream(disk)) + } + + return this.#exportContentAsRaw(id, type) + } } diff --git a/@xen-orchestra/rest-api/src/vdis/vdi.service.test.mts b/@xen-orchestra/rest-api/src/vdis/vdi.service.test.mts new file mode 100644 index 0000000000..9f4cfa8947 --- /dev/null +++ b/@xen-orchestra/rest-api/src/vdis/vdi.service.test.mts @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { Readable } from 'node:stream' +import { RandomAccessDisk } from '@xen-orchestra/disk-transform' +import { SUPPORTED_VDI_FORMAT } from '@vates/types' +import { toVhdStream } from 'vhd-lib/disk-consumer/index.mjs' +import { VHD_MAX_SIZE } from '@xen-orchestra/xapi' + +import { ApiError } from '../helpers/error.helper.mjs' +import { VdiService } from './vdi.service.mjs' +import type { DiskBlock } from '@xen-orchestra/disk-transform' +import type { RestApi } from '../rest-api/rest-api.mjs' +import type { XoVdi } from '@vates/types' + +const VDI_ID = 'c77f9955-c1d2-4b39-aa1c-73cdb2dacb7e' as XoVdi['id'] +const VDI_REF = 'OpaqueRef:vdi-1' + +const BLOCK_SIZE = 2 * 1024 * 1024 +const NB_BLOCKS = 4 +const VIRTUAL_SIZE = NB_BLOCKS * BLOCK_SIZE +// the disk is sparse: the formats that only store the allocated blocks must not announce +// the virtual size +const ALLOCATED_BLOCK_INDEXES = [0, 2] + +const EMPTY_BLOCK = Buffer.alloc(BLOCK_SIZE) +const buildBlockData = (index: number) => Buffer.alloc(BLOCK_SIZE, index + 1) + +/** + * Source of the VHD export the XAPI is mocked to return: the exports rebuilt by XO are + * generated from the blocks of this disk. + */ +class InMemoryDisk extends RandomAccessDisk { + getVirtualSize(): number { + return VIRTUAL_SIZE + } + getBlockSize(): number { + return BLOCK_SIZE + } + getBlockIndexes(): number[] { + return [...ALLOCATED_BLOCK_INDEXES] + } + hasBlock(index: number): boolean { + return ALLOCATED_BLOCK_INDEXES.includes(index) + } + async readBlock(index: number): Promise { + return { index, data: buildBlockData(index) } + } + async init(): Promise {} + async close(): Promise {} + isDifferencing(): boolean { + return false + } +} + +/** A raw export is byte for byte, unallocated blocks included. */ +function createRawExport(): Readable { + function* blocks() { + for (let index = 0; index < NB_BLOCKS; index++) { + yield ALLOCATED_BLOCK_INDEXES.includes(index) ? buildBlockData(index) : EMPTY_BLOCK + } + } + + return Readable.from(blocks(), { objectMode: false }) +} + +/** + * The XAPI of this VDI only knows how to export it as raw or VHD: the other formats are + * rebuilt by XO from the blocks of a `XapiDiskSource`, which is fed by the VHD export + * here since NBD is not available. + */ +function createRestApi() { + const exportContentCalls: unknown[][] = [] + + const xapi = { + // the disk source reads the format the VDI is stored in from its `sm_config` + getField: async (_type: string, _ref: string, field: string) => + field === 'sm_config' ? { 'image-format': SUPPORTED_VDI_FORMAT.vhd } : undefined, + VDI_exportContent: async (...args: unknown[]) => { + exportContentCalls.push(args) + const { format } = args[1] as { format: SUPPORTED_VDI_FORMAT } + + return format === SUPPORTED_VDI_FORMAT.raw ? createRawExport() : toVhdStream(new InMemoryDisk()) + }, + } + + const restApi = { + getObject: () => ({ id: VDI_ID, size: VIRTUAL_SIZE }), + getXapiObject: () => ({ $ref: VDI_REF, $xapi: xapi, virtual_size: VIRTUAL_SIZE }), + } as unknown as RestApi + + return { exportContentCalls, restApi } +} + +describe('VdiService.exportContent', () => { + for (const format of Object.values(SUPPORTED_VDI_FORMAT)) { + it(`announces, for the ${format} format, a length equal to the number of streamed bytes`, async () => { + const { restApi } = createRestApi() + + const exported = await new VdiService(restApi).exportContent(VDI_ID, 'VDI', { format }) + + // `length` is only exposed to the controllers, which turn it into a content-length: + // it is sent before the export is generated, an export streaming more or less than + // that breaks the download + const announcedLength = exported.length + assert.ok( + Number.isInteger(announcedLength) && (announcedLength as number) > 0, + `${format} must announce an integer length, got ${announcedLength}` + ) + + let streamedBytes = 0 + for await (const chunk of exported) { + streamedBytes += chunk.length + } + + assert.strictEqual(streamedBytes, announcedLength) + }) + } + + it('exports the raw format through the XAPI, with the virtual size as length', async () => { + const { exportContentCalls, restApi } = createRestApi() + + const exported = await new VdiService(restApi).exportContent(VDI_ID, 'VDI', { format: 'raw' }) + + // the XAPI does not announce the size of a raw export, XO computes it from the VDI + assert.strictEqual(exported.length, VIRTUAL_SIZE) + // a raw export is not rebuilt from the disk source: it is the export of the XAPI itself + assert.deepStrictEqual(exportContentCalls, [[VDI_REF, { format: 'raw' }]]) + + exported.destroy() + }) + + it('rejects a VHD export of a VDI larger than the VHD max size', async () => { + const size = VHD_MAX_SIZE + 1 + const restApi = { + getObject: () => ({ id: VDI_ID, size }), + getXapiObject: () => assert.fail('the disk must not be opened when the size is not supported'), + } as unknown as RestApi + + await assert.rejects(new VdiService(restApi).exportContent(VDI_ID, 'VDI', { format: 'vhd' }), (error: unknown) => { + assert(error instanceof ApiError) + assert.strictEqual(error.status, 422) + assert.deepStrictEqual(error.data, { maxSize: VHD_MAX_SIZE, size }) + return true + }) + }) +}) diff --git a/@xen-orchestra/rest-api/src/vms/vm.service.mts b/@xen-orchestra/rest-api/src/vms/vm.service.mts index 33a0d5e0e9..5ac276ccfe 100644 --- a/@xen-orchestra/rest-api/src/vms/vm.service.mts +++ b/@xen-orchestra/rest-api/src/vms/vm.service.mts @@ -303,6 +303,10 @@ export class VmService { for (const id in snapshotReplicas) { const snapshot = snapshotReplicas[id as XoVmSnapshot['id']] const timestamp = parseDateTime(snapshot.other['xo:backup:datetime']) + if (timestamp === null) { + // the snapshot has no backup date, it can't be the last replication + continue + } if (lastTimestamp === undefined || lastTimestamp < timestamp) { lastTimestamp = timestamp diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 4fd213ce1a..62e3f1c034 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -17,6 +17,7 @@ - [Rolling pool update/reboot] A pool can now skip the phase which brings the VMs back to the host they were running on, which halves the migrations of the run (PR [#10295](https://github.com/vatesfr/xen-orchestra/pull/10295)) - [XO5/Backups] Open the backup job edition form in the same tab when editing a backup job from the VM page (PR [#10342](https://github.com/vatesfr/xen-orchestra/pull/10342)) - [Web-Core/TabItem] Update the component to remove uppercase for better readability (PR [#10338](https://github.com/vatesfr/xen-orchestra/pull/10338)) +- [REST API] VDI can now be exported in qcow2 format, and the VHD export uses NBD when available. Both formats work whatever the format the disk is stored in. Every export format, raw included, now provides the size of the download (PR [#10350](https://github.com/vatesfr/xen-orchestra/pull/10350)) ### Bug fixes @@ -50,10 +51,12 @@ +- @vates/types minor - @xen-orchestra/backup-archive patch - @xen-orchestra/backups patch - @xen-orchestra/disk-cli patch - @xen-orchestra/qcow2 minor +- @xen-orchestra/rest-api minor - @xen-orchestra/web minor - @xen-orchestra/web-core minor - @xen-orchestra/xapi patch