mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
6 Commits
326c152fac
...
pbt_diskal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c13ec98b3 | ||
|
|
c19acbeb74 | ||
|
|
b2a6f26eb2 | ||
|
|
38fec907c1 | ||
|
|
8f761a2398 | ||
|
|
3b2ab4fd92 |
260
@xen-orchestra/backup-archive/src/disks/RemotePathDisk.mjs
Normal file
260
@xen-orchestra/backup-archive/src/disks/RemotePathDisk.mjs
Normal file
@@ -0,0 +1,260 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @typedef {import('@xen-orchestra/disk-transform').FileAccessor} FileAccessor
|
||||
*/
|
||||
|
||||
import { DiskAlias, AliasMissingError, AliasIsDirectoryError } from '@xen-orchestra/disk-transform'
|
||||
import { normalize } from '@xen-orchestra/fs/path'
|
||||
import { RemoteDisk } from './RemoteDisk.mjs'
|
||||
|
||||
/**
|
||||
* A RemoteDisk backed by a single handler + path (possibly an alias to another path).
|
||||
* Owns the alias-driven rename/unlink/clean/listAssociatedFiles logic so format-specific
|
||||
* subclasses (RemoteVhdDisk, and later others) don't have to reimplement it.
|
||||
*/
|
||||
export class RemotePathDisk extends RemoteDisk {
|
||||
/** @type {FileAccessor} */
|
||||
#handler
|
||||
|
||||
/** @type {string} */
|
||||
#path
|
||||
|
||||
/** @type {DiskAlias} */
|
||||
#alias
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {FileAccessor} params.handler
|
||||
* @param {string} params.path
|
||||
* @param {string} params.extension - e.g. 'vhd', used to build the `*.alias.<extension>` suffix
|
||||
*/
|
||||
constructor({ handler, path, extension }) {
|
||||
super()
|
||||
this.#handler = handler
|
||||
this.#path = path
|
||||
this.#alias = new DiskAlias(extension)
|
||||
}
|
||||
|
||||
/** @returns {FileAccessor} */
|
||||
get handler() {
|
||||
return this.#handler
|
||||
}
|
||||
|
||||
/** @returns {string} */
|
||||
get path() {
|
||||
return this.#path
|
||||
}
|
||||
|
||||
set path(newPath) {
|
||||
this.#path = newPath
|
||||
}
|
||||
|
||||
/** @returns {DiskAlias} */
|
||||
get alias() {
|
||||
return this.#alias
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract - true once the disk's format-specific state has been opened (e.g. after init()).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get isOpen() {
|
||||
throw new Error('isOpen must be implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract - throws if `target` isn't a valid, openable disk of this format.
|
||||
* @param {string} target
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async validateTarget(target) {
|
||||
throw new Error('validateTarget must be implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
getPath() {
|
||||
return this.path
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getPaths() {
|
||||
return [this.getPath()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename alias/disk
|
||||
* @param {string} newPath
|
||||
*/
|
||||
async rename(newPath) {
|
||||
const { handler, alias } = this
|
||||
if (alias.isAlias(newPath)) {
|
||||
const dataPath = await alias.resolve(handler, this.path)
|
||||
await handler.unlink(newPath)
|
||||
await alias.create(handler, newPath, dataPath)
|
||||
await handler.unlink(this.path)
|
||||
this.path = newPath
|
||||
} else {
|
||||
try {
|
||||
await handler.unlink(newPath)
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code === 'EISDIR') {
|
||||
await handler.rmtree(newPath).catch(() => {})
|
||||
}
|
||||
}
|
||||
await handler.rename(this.path, newPath)
|
||||
this.path = newPath
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes alias/disk
|
||||
* @param {Object} options
|
||||
* @param {boolean} [options.force]
|
||||
*/
|
||||
async unlink({ force = false } = {}) {
|
||||
const { handler, alias } = this
|
||||
|
||||
if (!this.isOpen) {
|
||||
if (!force) {
|
||||
throw new Error(`can't call unlink of a ${this.constructor.name} before init`)
|
||||
}
|
||||
await alias.unlink(handler, this.path)
|
||||
return
|
||||
}
|
||||
|
||||
await this.close()
|
||||
|
||||
if (alias.isAlias(this.path)) {
|
||||
try {
|
||||
await handler.unlink(await alias.resolve(handler, this.path))
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code === 'EISDIR') {
|
||||
await handler.rmtree(await alias.resolve(handler, this.path)).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await handler.unlink(this.path)
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code === 'EISDIR') {
|
||||
await handler.rmtree(this.path).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all file paths within dir that this disk claims.
|
||||
* For a plain disk: [path]
|
||||
* For an alias: [aliasPath, resolvedDataPath]
|
||||
* If the alias target cannot be resolved, only the alias path is returned.
|
||||
*
|
||||
* @param {string} dir
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async listAssociatedFiles(dir) {
|
||||
const { handler, alias } = this
|
||||
const prefix = normalize(dir.endsWith('/') ? dir : dir + '/')
|
||||
const isInDir = /** @param {string} p */ p => p === dir || p.startsWith(prefix)
|
||||
|
||||
const files = []
|
||||
if (isInDir(this.path)) {
|
||||
files.push(this.path)
|
||||
}
|
||||
|
||||
if (alias.isAlias(this.path)) {
|
||||
try {
|
||||
const resolved = await alias.resolve(handler, this.path)
|
||||
if (isInDir(resolved)) {
|
||||
files.push(resolved)
|
||||
}
|
||||
} catch {
|
||||
// broken alias, no data file to claim
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the integrity of this disk's alias reference.
|
||||
* Only meaningful for alias files; no-op for plain disks.
|
||||
* Returns the resolved target path when the alias is valid, undefined otherwise.
|
||||
*
|
||||
* @param {Object} [opts]
|
||||
* @param {boolean} [opts.remove]
|
||||
* @param {Function} [opts.logWarn]
|
||||
* @param {Function} [opts.logInfo]
|
||||
* @returns {Promise<string | undefined>}
|
||||
*/
|
||||
async clean({ remove = false, logWarn = () => {}, logInfo = () => {} } = {}) {
|
||||
const { handler, alias } = this
|
||||
|
||||
if (!alias.isAlias(this.path)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let target
|
||||
try {
|
||||
target = await alias.resolve(handler, this.path)
|
||||
} catch (err) {
|
||||
if (err instanceof AliasMissingError) {
|
||||
logWarn('missing target of alias', { alias: this.path })
|
||||
if (remove) {
|
||||
logInfo('removing alias with missing target', { alias: this.path })
|
||||
await handler.unlink(this.path)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (err instanceof AliasIsDirectoryError) {
|
||||
logWarn('alias is a directory', { alias: this.path })
|
||||
if (remove) {
|
||||
logInfo('removing directory named as alias', { alias: this.path })
|
||||
await alias.unlink(handler, this.path)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
logWarn('unhandled error while checking alias', { alias: this.path, err })
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (target === '') {
|
||||
logWarn('empty target for alias', { alias: this.path })
|
||||
if (remove) {
|
||||
logInfo('removing alias with empty target', { alias: this.path })
|
||||
await handler.unlink(this.path)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
await this.validateTarget(target)
|
||||
return target
|
||||
} catch (error) {
|
||||
if (error?.invalidTargetName) {
|
||||
logWarn('alias references an invalid target', { alias: this.path, target })
|
||||
if (remove) {
|
||||
logInfo('removing alias and invalid target', { alias: this.path, target })
|
||||
await handler.unlink(target)
|
||||
await handler.unlink(this.path)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
logWarn('missing or broken alias target', { alias: this.path, target, error })
|
||||
if (remove) {
|
||||
try {
|
||||
await alias.unlink(handler, this.path)
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logWarn('error deleting broken alias', { alias: this.path, target, err })
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,31 +5,21 @@
|
||||
* @typedef {import('vhd-lib/Vhd/VhdFile.js').VhdFile} VhdFile
|
||||
* @typedef {import('vhd-lib/_createFooterHeader.js').VhdFooter} VhdFooter
|
||||
* @typedef {import('@xen-orchestra/disk-transform').DiskBlock} DiskBlock
|
||||
* @typedef {import('@xen-orchestra/fs').RemoteHandlerAbstract} RemoteHandlerAbstract
|
||||
* @typedef {import('@xen-orchestra/disk-transform').FileAccessor} FileAccessor
|
||||
* @typedef {import('./RemoteDisk.mjs').RemoteDisk} RemoteDisk
|
||||
*
|
||||
|
||||
*/
|
||||
|
||||
import { openVhd, VhdAbstract, VhdDirectory } from 'vhd-lib'
|
||||
import { RemoteDisk } from './RemoteDisk.mjs'
|
||||
import { openVhd, VhdDirectory } from 'vhd-lib'
|
||||
import { RemotePathDisk } from './RemotePathDisk.mjs'
|
||||
import { DISK_TYPES } from 'vhd-lib/_constants.js'
|
||||
import { isVhdAlias, resolveVhdAlias } from 'vhd-lib/aliases.js'
|
||||
import { stringify } from 'uuid'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { RemoteVhdDiskChain } from './RemoteVhdDiskChain.mjs'
|
||||
import { normalize } from '@xen-orchestra/fs/path'
|
||||
|
||||
export class RemoteVhdDisk extends RemoteDisk {
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
#path
|
||||
|
||||
/**
|
||||
* @type {RemoteHandlerAbstract}
|
||||
*/
|
||||
#handler
|
||||
|
||||
export class RemoteVhdDisk extends RemotePathDisk {
|
||||
/**
|
||||
* @type {VhdFile | VhdDirectory | undefined}
|
||||
*/
|
||||
@@ -57,14 +47,19 @@ export class RemoteVhdDisk extends RemoteDisk {
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {RemoteHandlerAbstract} params.handler
|
||||
* @param {FileAccessor} params.handler
|
||||
* @param {string} params.path
|
||||
*/
|
||||
constructor({ handler, path }) {
|
||||
super()
|
||||
// @todo : ensure this is the full path from the root of the remote
|
||||
this.#path = path
|
||||
this.#handler = handler
|
||||
super({ handler, path, extension: 'vhd' })
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get isOpen() {
|
||||
return this.#vhd !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,12 +71,12 @@ export class RemoteVhdDisk extends RemoteDisk {
|
||||
async init(options = {}) {
|
||||
if (this.#vhd === undefined) {
|
||||
try {
|
||||
const { value, dispose } = await openVhd(this.#handler, await resolveVhdAlias(this.#handler, this.#path), {
|
||||
const { value, dispose } = await openVhd(this.handler, this.path, {
|
||||
checkSecondFooter: !options.force,
|
||||
})
|
||||
this.#vhd = value
|
||||
|
||||
if ((await this.isDirectory()) && !isVhdAlias(this.#path)) {
|
||||
if ((await this.isDirectory()) && !this.alias.isAlias(this.path)) {
|
||||
this.#vhd = undefined
|
||||
throw Object.assign(new Error("Can't init vhd directory without using alias"), { code: 'NOT_SUPPORTED' })
|
||||
}
|
||||
@@ -138,22 +133,6 @@ export class RemoteVhdDisk extends RemoteDisk {
|
||||
return this.#blockSize
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
getPath() {
|
||||
return this.#path
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the disk path in an array.
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getPaths() {
|
||||
return [this.getPath()]
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
@@ -187,7 +166,7 @@ export class RemoteVhdDisk extends RemoteDisk {
|
||||
}
|
||||
|
||||
const parentPath = this.#vhd.header.parentUnicodeName
|
||||
return normalize(join(dirname(this.#path), parentPath))
|
||||
return normalize(join(dirname(this.path), parentPath))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,13 +215,13 @@ export class RemoteVhdDisk extends RemoteDisk {
|
||||
}
|
||||
|
||||
const parentPath = this.#vhd.header.parentUnicodeName
|
||||
const fullParentPath = normalize(join(dirname(this.#path), parentPath))
|
||||
const fullParentPath = normalize(join(dirname(this.path), parentPath))
|
||||
|
||||
if (!parentPath) {
|
||||
throw new Error(`disk ${this.#path} doesn't have parents`)
|
||||
throw new Error(`disk ${this.path} doesn't have parents`)
|
||||
}
|
||||
|
||||
const parent = new RemoteVhdDisk({ handler: this.#handler, path: fullParentPath })
|
||||
const parent = new RemoteVhdDisk({ handler: this.handler, path: fullParentPath })
|
||||
return parent
|
||||
}
|
||||
/**
|
||||
@@ -300,7 +279,7 @@ export class RemoteVhdDisk extends RemoteDisk {
|
||||
(await childDisk.isDirectory())
|
||||
) {
|
||||
try {
|
||||
await this.#handler.rename(childDisk.getBlockPath(index), this.getBlockPath(index))
|
||||
await this.handler.rename(childDisk.getBlockPath(index), this.getBlockPath(index))
|
||||
|
||||
this.setAllocatedBlocks([index])
|
||||
} catch (error) {
|
||||
@@ -434,92 +413,7 @@ export class RemoteVhdDisk extends RemoteDisk {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename alias/disk
|
||||
* @param {string} newPath
|
||||
*/
|
||||
async rename(newPath) {
|
||||
if (isVhdAlias(newPath)) {
|
||||
const dataPath = await resolveVhdAlias(this.#handler, this.#path)
|
||||
|
||||
await this.#handler.unlink(newPath)
|
||||
await VhdAbstract.createAlias(this.#handler, newPath, dataPath)
|
||||
await this.#handler.unlink(this.#path)
|
||||
|
||||
this.#path = newPath
|
||||
} else {
|
||||
try {
|
||||
await this.#handler.unlink(newPath)
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code === 'EISDIR') {
|
||||
await this.#handler.rmtree(newPath).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
await this.#handler.rename(this.#path, newPath)
|
||||
|
||||
this.#path = newPath
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes disk
|
||||
* @param {Object} options
|
||||
*/
|
||||
async unlink({ force = false } = {}) {
|
||||
if (this.#vhd === undefined) {
|
||||
if (force) {
|
||||
let resolved = this.#path
|
||||
try {
|
||||
resolved = await resolveVhdAlias(this.#handler, this.#path)
|
||||
} catch (err) {
|
||||
// broken vhd directory must be unlinkable
|
||||
if (err.code !== 'EISDIR') {
|
||||
throw err
|
||||
}
|
||||
// warn('Deleting directly a VhdDirectory', { this.#path, err })
|
||||
}
|
||||
try {
|
||||
await this.#handler.unlink(resolved)
|
||||
} catch (err) {
|
||||
if (err.code === 'EISDIR') {
|
||||
await this.#handler.rmtree(resolved)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// also delete the alias file
|
||||
if (this.#path !== resolved) {
|
||||
await this.#handler.unlink(this.#path)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`can't call unlink of a RemoteVhdDisk before init`)
|
||||
}
|
||||
} else {
|
||||
await this.close()
|
||||
|
||||
if (isVhdAlias(this.#path)) {
|
||||
try {
|
||||
await this.#handler.unlink(await resolveVhdAlias(this.#handler, this.#path))
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code === 'EISDIR') {
|
||||
await this.#handler.rmtree(await resolveVhdAlias(this.#handler, this.#path)).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#handler.unlink(this.#path)
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code === 'EISDIR') {
|
||||
await this.#handler.rmtree(this.#path).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the disk is a VHD directory.
|
||||
* Checks if the disk is a VHD directory.
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isDirectory() {
|
||||
@@ -531,116 +425,19 @@ export class RemoteVhdDisk extends RemoteDisk {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all file paths within dir that this disk claims.
|
||||
* For a plain VHD or VHD directory: [path]
|
||||
* For a VHD alias: [aliasPath, resolvedDataPath]
|
||||
* If the alias target cannot be resolved, only the alias path is returned.
|
||||
*
|
||||
* @param {string} dir
|
||||
* @returns {Promise<string[]>}
|
||||
* Throws if `target` isn't a valid, openable VHD.
|
||||
* @param {string} target
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async listAssociatedFiles(dir) {
|
||||
const prefix = normalize(dir.endsWith('/') ? dir : dir + '/')
|
||||
const isInDir = /** @param {string} p */ p => p === dir || p.startsWith(prefix)
|
||||
|
||||
const files = []
|
||||
if (isInDir(this.#path)) {
|
||||
files.push(this.#path)
|
||||
async validateTarget(target) {
|
||||
if (!this.alias.isAlias(target) && !target.endsWith('.vhd')) {
|
||||
throw Object.assign(new Error(`${target} is not a vhd target`), { invalidTargetName: true })
|
||||
}
|
||||
|
||||
if (isVhdAlias(this.#path)) {
|
||||
try {
|
||||
const resolved = await resolveVhdAlias(this.#handler, this.#path)
|
||||
if (isInDir(resolved)) {
|
||||
files.push(resolved)
|
||||
}
|
||||
} catch {
|
||||
// broken alias, no data file to claim
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the integrity of this disk's alias reference.
|
||||
* Only meaningful for alias files (.alias.vhd); no-op for plain VHDs.
|
||||
* Returns the resolved target path when the alias is valid, undefined otherwise.
|
||||
*
|
||||
* @param {Object} [opts]
|
||||
* @param {boolean} [opts.remove]
|
||||
* @param {Function} [opts.logWarn]
|
||||
* @param {Function} [opts.logInfo]
|
||||
* @returns {Promise<string | undefined>}
|
||||
*/
|
||||
async clean({ remove = false, logWarn = () => {}, logInfo = () => {} } = {}) {
|
||||
if (!isVhdAlias(this.#path)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let target
|
||||
const { dispose } = await openVhd(this.handler, target)
|
||||
try {
|
||||
target = await resolveVhdAlias(this.#handler, this.#path)
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
logWarn('missing target of alias', { alias: this.#path })
|
||||
if (remove) {
|
||||
logInfo('removing alias with missing target', { alias: this.#path })
|
||||
await this.#handler.unlink(this.#path)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (err.code === 'EISDIR') {
|
||||
logWarn('alias is a vhd directory', { alias: this.#path })
|
||||
if (remove) {
|
||||
logInfo('removing vhd directory named as alias', { alias: this.#path })
|
||||
await VhdAbstract.unlink(this.#handler, this.#path)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
logWarn('unhandled error while checking alias', { alias: this.#path, err })
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (target === '') {
|
||||
logWarn('empty target for alias', { alias: this.#path })
|
||||
if (remove) {
|
||||
logInfo('removing alias with empty target', { alias: this.#path })
|
||||
await this.#handler.unlink(this.#path)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!isVhdAlias(target) && !target.endsWith('.vhd')) {
|
||||
logWarn('alias references non VHD target', { alias: this.#path, target })
|
||||
if (remove) {
|
||||
logInfo('removing alias and non VHD target', { alias: this.#path, target })
|
||||
await this.#handler.unlink(target)
|
||||
await this.#handler.unlink(this.#path)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const { dispose } = await openVhd(this.#handler, target)
|
||||
try {
|
||||
await dispose()
|
||||
} catch (_) {
|
||||
// errors during dispose should not trigger deletion
|
||||
}
|
||||
return target
|
||||
} catch (error) {
|
||||
logWarn('missing or broken alias target', { alias: this.#path, target, error })
|
||||
if (remove) {
|
||||
try {
|
||||
await VhdAbstract.unlink(this.#handler, this.#path)
|
||||
} catch (/** @type {any} */ err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logWarn('error deleting broken alias', { alias: this.#path, target, err })
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
await dispose()
|
||||
} catch (_) {
|
||||
// errors during dispose should not trigger deletion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@xen-orchestra/fs": "^4.9.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"@types/node": "^22.3.0",
|
||||
"typescript": "~5.6",
|
||||
@@ -17,6 +18,7 @@
|
||||
"dev": "tsc --watch",
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "tsc && node --test **/*.test.mjs",
|
||||
"test-integration": "node --test **/*.integ.mjs",
|
||||
"clean": "rimraf dist/"
|
||||
},
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/disk-transform",
|
||||
|
||||
109
@xen-orchestra/disk-transform/src/DiskAlias.integ.mts
Normal file
109
@xen-orchestra/disk-transform/src/DiskAlias.integ.mts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { afterEach, beforeEach, test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { mkdtemp } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { rimraf } from 'rimraf'
|
||||
import { getSyncedHandler } from '@xen-orchestra/fs'
|
||||
import type { FileAccessor } from './FileAccessor.mjs'
|
||||
import {
|
||||
AliasChainError,
|
||||
AliasIsDirectoryError,
|
||||
AliasMissingError,
|
||||
AliasTooLongError,
|
||||
ALIAS_MAX_PATH_LENGTH,
|
||||
DiskAlias,
|
||||
} from './DiskAlias.mjs'
|
||||
|
||||
let tempDir: string
|
||||
let dispose: () => Promise<void>
|
||||
let handler: FileAccessor
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'disk-alias-'))
|
||||
const synced = await getSyncedHandler({ url: `file://${tempDir}` })
|
||||
handler = synced.value as unknown as FileAccessor
|
||||
dispose = synced.dispose
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await dispose()
|
||||
await rimraf(tempDir)
|
||||
})
|
||||
|
||||
test('DiskAlias resolve()', async t => {
|
||||
await t.test('gets the path of the target file for an alias, same directory', async () => {
|
||||
const vhdAlias = new DiskAlias('vhd')
|
||||
const alias = 'alias.alias.vhd'
|
||||
await handler.writeFile(alias, 'target.vhd')
|
||||
assert.equal(await vhdAlias.resolve(handler, alias), 'target.vhd')
|
||||
})
|
||||
|
||||
await t.test('gets the path of the target file for an alias, different directory', async () => {
|
||||
const vhdAlias = new DiskAlias('vhd')
|
||||
const alias = 'alias.alias.vhd'
|
||||
await handler.mktree('sub')
|
||||
await handler.writeFile(alias, 'sub/target.vhd', { flags: 'w' })
|
||||
assert.equal(await vhdAlias.resolve(handler, alias), 'sub/target.vhd')
|
||||
})
|
||||
|
||||
await t.test('throws AliasChainError on an alias to an alias', async () => {
|
||||
const vhdAlias = new DiskAlias('vhd')
|
||||
const alias = 'alias.alias.vhd'
|
||||
const target = 'target.alias.vhd'
|
||||
await handler.writeFile(alias, target)
|
||||
await assert.rejects(async () => await vhdAlias.resolve(handler, alias), AliasChainError)
|
||||
})
|
||||
|
||||
await t.test('throws AliasTooLongError on a file too big', async () => {
|
||||
const vhdAlias = new DiskAlias('vhd')
|
||||
await handler.writeFile('toobig.alias.vhd', '0'.repeat(ALIAS_MAX_PATH_LENGTH + 1))
|
||||
await assert.rejects(async () => await vhdAlias.resolve(handler, 'toobig.alias.vhd'), AliasTooLongError)
|
||||
})
|
||||
|
||||
await t.test('throws AliasMissingError when the alias file does not exist', async () => {
|
||||
const vhdAlias = new DiskAlias('vhd')
|
||||
await assert.rejects(async () => await vhdAlias.resolve(handler, 'missing.alias.vhd'), AliasMissingError)
|
||||
})
|
||||
|
||||
await t.test('throws AliasIsDirectoryError when the alias path is a directory', async () => {
|
||||
const vhdAlias = new DiskAlias('vhd')
|
||||
await handler.mktree('broken.alias.vhd')
|
||||
await assert.rejects(async () => await vhdAlias.resolve(handler, 'broken.alias.vhd'), AliasIsDirectoryError)
|
||||
})
|
||||
})
|
||||
|
||||
test('DiskAlias create()', async t => {
|
||||
await t.test('writes the relative path from the alias to the target', async () => {
|
||||
const vhdAlias = new DiskAlias('vhd')
|
||||
await handler.mktree('data')
|
||||
await vhdAlias.create(handler, 'disk.alias.vhd', 'data/disk.vhd')
|
||||
assert.equal((await handler.readFile('disk.alias.vhd')).toString(), 'data/disk.vhd')
|
||||
})
|
||||
|
||||
await t.test('rejects chaining alias to alias', async () => {
|
||||
const vhdAlias = new DiskAlias('vhd')
|
||||
await assert.rejects(
|
||||
async () => await vhdAlias.create(handler, 'disk.alias.vhd', 'other.alias.vhd'),
|
||||
AliasChainError
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test('DiskAlias unlink()', async t => {
|
||||
await t.test('removes the alias file and its target', async () => {
|
||||
const vhdAlias = new DiskAlias('vhd')
|
||||
await handler.writeFile('data.vhd', 'content')
|
||||
await vhdAlias.create(handler, 'disk.alias.vhd', 'data.vhd')
|
||||
await vhdAlias.unlink(handler, 'disk.alias.vhd')
|
||||
await assert.rejects(async () => await handler.getSize('disk.alias.vhd'))
|
||||
await assert.rejects(async () => await handler.getSize('data.vhd'))
|
||||
})
|
||||
|
||||
await t.test('removes a broken alias that is actually a directory', async () => {
|
||||
const vhdAlias = new DiskAlias('vhd')
|
||||
await handler.mktree('broken.alias.vhd')
|
||||
await vhdAlias.unlink(handler, 'broken.alias.vhd')
|
||||
await assert.rejects(async () => await handler.getSize('broken.alias.vhd'))
|
||||
})
|
||||
})
|
||||
121
@xen-orchestra/disk-transform/src/DiskAlias.mts
Normal file
121
@xen-orchestra/disk-transform/src/DiskAlias.mts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { dirname, relative, resolve } from 'node:path'
|
||||
import type { FileAccessor } from './FileAccessor.mjs'
|
||||
|
||||
export const ALIAS_MAX_PATH_LENGTH = 1024
|
||||
|
||||
export class DiskAliasError extends Error {}
|
||||
export class AliasMissingError extends DiskAliasError {
|
||||
code = 'ENOENT'
|
||||
}
|
||||
export class AliasIsDirectoryError extends DiskAliasError {
|
||||
code = 'EISDIR'
|
||||
}
|
||||
export class AliasChainError extends DiskAliasError {}
|
||||
export class AliasTooLongError extends DiskAliasError {}
|
||||
|
||||
function resolveRelativeFromFile(file: string, targetPath: string): string {
|
||||
if (file.startsWith('/')) {
|
||||
return resolve(dirname(file), targetPath)
|
||||
}
|
||||
return resolve('/', dirname(file), targetPath).slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* an alias is a small text file containing the path, relative to itself,
|
||||
* of the disk (file or directory) it points to
|
||||
*/
|
||||
export class DiskAlias {
|
||||
#extension: string
|
||||
|
||||
constructor(extension: string) {
|
||||
this.#extension = extension
|
||||
}
|
||||
|
||||
get suffix(): string {
|
||||
return `.alias.${this.#extension}`
|
||||
}
|
||||
|
||||
isAlias(filename: string): boolean {
|
||||
return filename.endsWith(this.suffix)
|
||||
}
|
||||
|
||||
async resolve(handler: FileAccessor, filename: string): Promise<string> {
|
||||
if (!this.isAlias(filename)) {
|
||||
return filename
|
||||
}
|
||||
|
||||
let aliasContent: string
|
||||
try {
|
||||
aliasContent = (await handler.readFile(filename)).toString().trim()
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') {
|
||||
throw new AliasMissingError(`The alias file ${filename} does not exist`)
|
||||
}
|
||||
if (err.code === 'EISDIR') {
|
||||
throw new AliasIsDirectoryError(`The alias file ${filename} is a directory`)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!handler.isEncrypted && aliasContent.length > ALIAS_MAX_PATH_LENGTH) {
|
||||
throw new AliasTooLongError(`The alias file ${filename} is too long (${aliasContent.length} chars)`)
|
||||
}
|
||||
|
||||
// also handle circular references and unreasonably long chains
|
||||
if (this.isAlias(aliasContent)) {
|
||||
throw new AliasChainError(`Chaining alias is forbidden ${filename} to ${aliasContent}`)
|
||||
}
|
||||
|
||||
// the target is relative to the alias location
|
||||
return resolveRelativeFromFile(filename, aliasContent)
|
||||
}
|
||||
|
||||
async create(handler: FileAccessor, aliasPath: string, targetPath: string): Promise<void> {
|
||||
if (!this.isAlias(aliasPath)) {
|
||||
throw new DiskAliasError(`Alias must be named *${this.suffix}, ${aliasPath} given`)
|
||||
}
|
||||
if (this.isAlias(targetPath)) {
|
||||
throw new AliasChainError(`Chaining alias is forbidden ${aliasPath} to ${targetPath}`)
|
||||
}
|
||||
|
||||
// aliasPath and targetPath are absolute path from the root of the handler
|
||||
// normalize them so they can't escape this dir
|
||||
const aliasDir = dirname(resolve('/', aliasPath))
|
||||
// only store the relative path from alias to target
|
||||
const relativePathToTarget = relative(aliasDir, resolve('/', targetPath))
|
||||
|
||||
if (relativePathToTarget.length > ALIAS_MAX_PATH_LENGTH) {
|
||||
throw new AliasTooLongError(
|
||||
`Alias relative path ${relativePathToTarget} is too long : ${relativePathToTarget.length} chars, max is ${ALIAS_MAX_PATH_LENGTH}`
|
||||
)
|
||||
}
|
||||
await handler.writeFile(aliasPath, relativePathToTarget)
|
||||
}
|
||||
|
||||
async unlink(handler: FileAccessor, path: string): Promise<void> {
|
||||
let resolved = path
|
||||
try {
|
||||
resolved = await this.resolve(handler, path)
|
||||
} catch (err) {
|
||||
// broken alias (named like an alias but is actually a directory) must still be unlinkable
|
||||
if (!(err instanceof AliasIsDirectoryError)) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await handler.unlink(resolved)
|
||||
} catch (err: any) {
|
||||
if (err.code === 'EISDIR') {
|
||||
await handler.rmtree(resolved)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// also delete the alias file
|
||||
if (path !== resolved) {
|
||||
await handler.unlink(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
25
@xen-orchestra/disk-transform/src/DiskAlias.test.mts
Normal file
25
@xen-orchestra/disk-transform/src/DiskAlias.test.mts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { DiskAlias } from './DiskAlias.mjs'
|
||||
|
||||
test('DiskAlias', async t => {
|
||||
await t.test('isAlias() recognizes only *.alias.<extension> files', () => {
|
||||
const vhdAlias = new DiskAlias('vhd')
|
||||
assert.equal(vhdAlias.isAlias('filename.alias.vhd'), true)
|
||||
assert.equal(vhdAlias.isAlias('alias.vhd'), false)
|
||||
assert.equal(vhdAlias.isAlias('filename.vhd'), false)
|
||||
assert.equal(vhdAlias.isAlias('filename.alias.vhd.other'), false)
|
||||
})
|
||||
|
||||
await t.test('isAlias() is scoped to its own extension', () => {
|
||||
const vhdAlias = new DiskAlias('vhd')
|
||||
const qcow2Alias = new DiskAlias('qcow2')
|
||||
assert.equal(vhdAlias.isAlias('filename.alias.qcow2'), false)
|
||||
assert.equal(qcow2Alias.isAlias('filename.alias.qcow2'), true)
|
||||
})
|
||||
|
||||
await t.test('resolve() returns the path in argument for a non alias file', async () => {
|
||||
const vhdAlias = new DiskAlias('vhd')
|
||||
assert.equal(await vhdAlias.resolve(undefined as any, 'filename.vhd'), 'filename.vhd')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Readable } from 'node:stream'
|
||||
|
||||
export type FileDescriptor = number
|
||||
/** A file descriptor as returned by openFile() - implementation-defined (e.g. a number on local fs) */
|
||||
export interface FileArg {
|
||||
fd: unknown
|
||||
path: string
|
||||
}
|
||||
export type FileDescriptor = number | string | FileArg
|
||||
|
||||
/**
|
||||
* a base interface is defined to be able to add other accessor ,
|
||||
@@ -13,10 +18,10 @@ export type FileDescriptor = number
|
||||
export interface FileAccessor {
|
||||
isEncrypted: boolean
|
||||
list(path: string, opts?: unknown): Promise<string[]>
|
||||
closeFile(filehandle: number): Promise<void>
|
||||
openFile(path: string, opts?: unknown): Promise<number>
|
||||
closeFile(filehandle: FileDescriptor): Promise<void>
|
||||
openFile(path: string, opts?: unknown): Promise<FileDescriptor>
|
||||
read(
|
||||
file: string | number,
|
||||
file: FileDescriptor,
|
||||
buffer: Buffer | DataView,
|
||||
position: number
|
||||
): Promise<{ bytesRead: number; buffer: Buffer | DataView }>
|
||||
@@ -26,6 +31,6 @@ export interface FileAccessor {
|
||||
mktree(path: string): Promise<void>
|
||||
rmtree(path: string): Promise<void>
|
||||
unlink(path: string): Promise<void>
|
||||
outputStream(stream: Readable): Promise<void>
|
||||
outputStream(path: string, input: Readable, opts?: unknown): Promise<void>
|
||||
rename(path: string, newPath: string): Promise<void>
|
||||
}
|
||||
|
||||
@@ -1,63 +1,60 @@
|
||||
import { DiskBlock, RandomAccessDisk } from "./Disk.mjs";
|
||||
import { FileAccessor } from "./FileAccessor.mjs";
|
||||
import { DiskBlock, RandomAccessDisk } from './Disk.mjs'
|
||||
import { FileAccessor, FileDescriptor } from './FileAccessor.mjs'
|
||||
|
||||
export class RawDisk extends RandomAccessDisk {
|
||||
#accessor
|
||||
#path
|
||||
#descriptor: FileDescriptor | undefined
|
||||
#blockSize: number
|
||||
#size: number | undefined
|
||||
|
||||
export class RawDisk extends RandomAccessDisk{
|
||||
#accessor
|
||||
#path
|
||||
#descriptor:number|undefined
|
||||
#blockSize:number
|
||||
#size: number|undefined
|
||||
|
||||
|
||||
constructor(accessor: FileAccessor, path: string, blockSize:number){
|
||||
super()
|
||||
this.#accessor= accessor
|
||||
this.#path = path
|
||||
this.#blockSize = blockSize
|
||||
constructor(accessor: FileAccessor, path: string, blockSize: number) {
|
||||
super()
|
||||
this.#accessor = accessor
|
||||
this.#path = path
|
||||
this.#blockSize = blockSize
|
||||
}
|
||||
async readBlock(index: number): Promise<DiskBlock> {
|
||||
if (this.#descriptor === undefined) {
|
||||
throw new Error("Can't call readBlock before init")
|
||||
}
|
||||
async readBlock(index: number): Promise<DiskBlock> {
|
||||
if(this.#descriptor === undefined){
|
||||
throw new Error("Can't call readBlock before init");
|
||||
}
|
||||
const data = Buffer.alloc(this.getBlockSize(), 0)
|
||||
await this.#accessor.read(this.#descriptor, data, index*this.getBlockSize())
|
||||
return {
|
||||
index,
|
||||
data
|
||||
}
|
||||
const data = Buffer.alloc(this.getBlockSize(), 0)
|
||||
await this.#accessor.read(this.#descriptor, data, index * this.getBlockSize())
|
||||
return {
|
||||
index,
|
||||
data,
|
||||
}
|
||||
getVirtualSize(): number {
|
||||
if(this.#size === undefined){
|
||||
throw new Error("Can't call getVirtualsize before init");
|
||||
}
|
||||
return this.#size
|
||||
}
|
||||
getVirtualSize(): number {
|
||||
if (this.#size === undefined) {
|
||||
throw new Error("Can't call getVirtualsize before init")
|
||||
}
|
||||
getBlockSize(): number {
|
||||
return this.#blockSize
|
||||
return this.#size
|
||||
}
|
||||
getBlockSize(): number {
|
||||
return this.#blockSize
|
||||
}
|
||||
async init(): Promise<void> {
|
||||
this.#descriptor = await this.#accessor.openFile(this.#path)
|
||||
this.#size = await this.#accessor.getSize(this.#path)
|
||||
}
|
||||
async close(): Promise<void> {
|
||||
this.#descriptor && (await this.#accessor.closeFile(this.#descriptor))
|
||||
this.#descriptor = undefined
|
||||
this.#size = undefined
|
||||
}
|
||||
isDifferencing(): boolean {
|
||||
return false
|
||||
}
|
||||
getBlockIndexes(): Array<number> {
|
||||
const nbBlocks = Math.ceil(this.getVirtualSize() / this.getBlockSize())
|
||||
const index = []
|
||||
for (let i = 0; i < nbBlocks; i++) {
|
||||
index.push(i)
|
||||
}
|
||||
async init(): Promise<void> {
|
||||
this.#descriptor = await this.#accessor.openFile(this.#path)
|
||||
this.#size = await this.#accessor.getSize(this.#path)
|
||||
}
|
||||
async close(): Promise<void> {
|
||||
this.#descriptor && await this.#accessor.closeFile(this.#descriptor)
|
||||
this.#descriptor = undefined
|
||||
this.#size = undefined
|
||||
}
|
||||
isDifferencing(): boolean {
|
||||
return false
|
||||
}
|
||||
getBlockIndexes(): Array<number> {
|
||||
const nbBlocks = Math.ceil(this.getVirtualSize()/ this.getBlockSize())
|
||||
const index =[]
|
||||
for(let i=0; i < nbBlocks; i ++){
|
||||
index.push(i)
|
||||
}
|
||||
return index
|
||||
}
|
||||
hasBlock(index: number): boolean {
|
||||
return index * this.getBlockSize() < this.getVirtualSize()
|
||||
}
|
||||
|
||||
}
|
||||
return index
|
||||
}
|
||||
hasBlock(index: number): boolean {
|
||||
return index * this.getBlockSize() < this.getVirtualSize()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
export { Disk, DiskBlock, RandomAccessDisk } from './Disk.mjs'
|
||||
export {
|
||||
DiskAlias,
|
||||
DiskAliasError,
|
||||
AliasMissingError,
|
||||
AliasIsDirectoryError,
|
||||
AliasChainError,
|
||||
AliasTooLongError,
|
||||
ALIAS_MAX_PATH_LENGTH,
|
||||
} from './DiskAlias.mjs'
|
||||
export { DiskChain } from './DiskChain.mjs'
|
||||
export { DiskLargerBlock } from './DiskLargerBlock.mjs'
|
||||
export { DiskSmallerBlock } from './DiskSmallerBlock.mjs'
|
||||
export { NegativeDisk } from './NegativeDisk.mjs'
|
||||
export { FileAccessor } from './FileAccessor.mjs'
|
||||
export { FileAccessor, FileArg, FileDescriptor } from './FileAccessor.mjs'
|
||||
export { DiskPassthrough, RandomDiskPassthrough } from './DiskPassthrough.mjs'
|
||||
export { RawDisk } from './RawDisk.mjs'
|
||||
export { ReadAhead } from './ReadAhead.mjs'
|
||||
|
||||
@@ -164,3 +164,13 @@ export abstract class RemoteHandlerAbstract {
|
||||
abstract useVhdDirectory(): boolean
|
||||
abstract addPrefix(prefix: string): RemoteHandlerAbstract
|
||||
}
|
||||
|
||||
// ─── Handler factories ───────────────────────────────────────────────────────
|
||||
|
||||
export interface SyncedHandler {
|
||||
dispose: () => Promise<void>
|
||||
value: RemoteHandlerAbstract
|
||||
}
|
||||
|
||||
export declare function getHandler(remote: RemoteInfo, ...rest: unknown[]): RemoteHandlerAbstract
|
||||
export declare function getSyncedHandler(remote: RemoteInfo, ...rest: unknown[]): Promise<SyncedHandler>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { DiskBlock, FileAccessor } from "@xen-orchestra/disk-transform"
|
||||
import { QcowDisk } from "./QcowDisk.mjs"
|
||||
import { DiskBlock, FileAccessor, FileDescriptor } from '@xen-orchestra/disk-transform'
|
||||
import { QcowDisk } from './QcowDisk.mjs'
|
||||
|
||||
export class QCowAccessor extends QcowDisk {
|
||||
|
||||
#accessor: FileAccessor
|
||||
#path: string
|
||||
#descriptor: number | undefined
|
||||
#descriptor: FileDescriptor | undefined
|
||||
|
||||
constructor(accessor: FileAccessor, path: string) {
|
||||
super()
|
||||
@@ -23,7 +22,7 @@ export class QCowAccessor extends QcowDisk {
|
||||
throw new Error("Can't get file decriptor before init")
|
||||
}
|
||||
const buffer = Buffer.alloc(length)
|
||||
await this.#accessor.read(this.#descriptor , buffer, offset)
|
||||
await this.#accessor.read(this.#descriptor, buffer, offset)
|
||||
return buffer
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
- [XO6/VM] Add "New VM" button on Host view (PR [#10048](https://github.com/vatesfr/xen-orchestra/pull/10048))
|
||||
- [xo-server] expose more metrics when doing a memory dump (PR [#10041](https://github.com/vatesfr/xen-orchestra/pull/10041))
|
||||
- [RPU] Re-enable the load balancer after a configurable safe delay (30 minutes by default) when a rolling pool update ends (PR [#10111](https://github.com/vatesfr/xen-orchestra/pull/10111))
|
||||
- [Tasks] Resolve objects in tasks names [Forum#100894]([https://xcp-ng.org/forum/post/100894](https://xcp-ng.org/forum/post/100894)) (PR [#9830](https://github.com/vatesfr/xen-orchestra/pull/9830))
|
||||
- [Tasks] Resolve objects in tasks names [Forum#100894](<[https://xcp-ng.org/forum/post/100894](https://xcp-ng.org/forum/post/100894)>) (PR [#9830](https://github.com/vatesfr/xen-orchestra/pull/9830))
|
||||
- [Backups] Add DiskAlias to handle generic aliases (PR [#10144](https://github.com/vatesfr/xen-orchestra/pull/10144))
|
||||
- [Backups] RemoteVhdDisk now inherits from RemotePathDisk which handles generic aliases (PR [#10144](https://github.com/vatesfr/xen-orchestra/pull/10144))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
@@ -69,7 +71,9 @@
|
||||
- @xen-orchestra/backup-archive minor
|
||||
- @xen-orchestra/backups patch
|
||||
- @xen-orchestra/disk-cli minor
|
||||
- @xen-orchestra/disk-transform patch
|
||||
- @xen-orchestra/disk-transform minor
|
||||
- @xen-orchestra/fs patch
|
||||
- @xen-orchestra/qcow2 patch
|
||||
- @xen-orchestra/rest-api minor
|
||||
- @xen-orchestra/web minor
|
||||
- @xen-orchestra/web-core minor
|
||||
|
||||
Reference in New Issue
Block a user