Compare commits

...

6 Commits

Author SHA1 Message Date
Pierre Brunet
9c13ec98b3 fix: small issue for alias length/size 2026-07-23 09:23:27 +02:00
Pierre Brunet
c19acbeb74 fix: update type imports 2026-07-21 17:44:32 +02:00
Pierre Brunet
b2a6f26eb2 chore: update changelog 2026-07-21 17:41:14 +02:00
Pierre Brunet
38fec907c1 feat: refactor RemoteVhdDisk to use RemotePathDisk 2026-07-21 17:35:23 +02:00
Pierre Brunet
8f761a2398 fix: small typo issues in fs types 2026-07-21 17:34:45 +02:00
Pierre Brunet
3b2ab4fd92 feat: add DiskAlias class to handle generic aliases in remoteDisks 2026-07-21 17:34:07 +02:00
12 changed files with 643 additions and 305 deletions

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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