Compare commits

..

1 Commits

Author SHA1 Message Date
Pierre Brunet
88495d763d feat: aggregate errors in backup runners 2026-05-20 17:10:43 +02:00
1129 changed files with 14740 additions and 50146 deletions

View File

@@ -3,5 +3,4 @@
**/*.hbs
# Generated files
/@xen-orchestra/web/src/route-map.d.ts
/@xen-orchestra/lite/src/route-map.d.ts
/@xen-orchestra/web/typed-router.d.ts

View File

@@ -1,12 +1,12 @@
import Fuse from 'fuse-native'
import LRU from 'lru-cache'
import Fuse from 'fuse-native'
import { createLogger } from '@xen-orchestra/log'
import { VhdSynthetic } from 'vhd-lib'
import { Disposable, fromCallback } from 'promise-toolbox'
const { warn } = createLogger('vates:fuse-vhd')
// build a stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
// build a s stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
const stat = st => ({
mtime: st.mtime || new Date(),
atime: st.atime || new Date(),
@@ -20,109 +20,42 @@ const stat = st => ({
export const mount = Disposable.factory(async function* mount(handler, diskPath, mountDir) {
const vhd = yield VhdSynthetic.fromVhdChain(handler, diskPath)
const cache = new LRU({
max: 16, // each cached block is 2MB in size
})
await vhd.readBlockAllocationTable()
const { blockSize } = vhd.header
// A zeroed buffer returned for VHD blocks that are not present in the chain.
// Never mutated — safe to share across all reads.
const EMPTY_BLOCK = Buffer.alloc(blockSize, 0)
// --- coalescing block read queue ---
//
// FUSE read requests from NTFS-3g arrive in small chunks (typically 4 KiB)
// while VHD blocks are 2 MiB. Reading blocks one at a time from the backing
// store (CIFS/NFS/…) bounds libuv threadpool usage to 1 concurrent I/O call.
//
// Coalescing: all pending requests for the same blockId share a single
// readBlock() call. When the block resolves, every waiter is fulfilled at once.
//
// Together these two properties prevent the FUSE-on-FUSE threadpool deadlock:
// concurrent NTFS-3g reads no longer starve the VHD block reads they depend on.
// In-memory block cache. Each VHD block is 2 MiB; 16 blocks = 32 MiB.
// This is the primary tool against the 300× CIFS amplification: NTFS-3g
// reads a 2 MiB block in ~512 sequential 4 KiB FUSE requests. Without a
// cache, each request causes a full 2 MiB CIFS read. With the cache, only
// the first request reads from CIFS; the rest are served from RAM.
const blockCache = new LRU({ max: 64 })
const pendingBlocks = new Map() // blockId → Promise<Buffer>
const readQueue = [] // { blockId, resolve, reject }[]
let queueRunning = false
// log stats every 10 s while there is activity
function enqueueBlock(blockId) {
// 1. serve from cache if available
if (blockCache.has(blockId)) {
return Promise.resolve(blockCache.get(blockId))
}
// 2. coalesce: join an existing in-flight read for this block
if (pendingBlocks.has(blockId)) {
return pendingBlocks.get(blockId)
}
const p = new Promise((resolve, reject) => {
readQueue.push({ blockId, resolve, reject })
})
pendingBlocks.set(blockId, p)
p.then(
data => {
pendingBlocks.delete(blockId)
blockCache.set(blockId, data)
},
() => pendingBlocks.delete(blockId)
)
drainQueue()
return p
}
function drainQueue() {
if (queueRunning) return
queueRunning = true
// async IIFE — errors are forwarded to individual reject() callbacks,
// so the outer floating promise is always fulfilled
;(async () => {
while (readQueue.length > 0) {
const { blockId, resolve, reject } = readQueue.shift()
try {
resolve((await vhd.readBlock(blockId)).data)
} catch (err) {
reject(err)
}
}
queueRunning = false
})()
}
async function readData(buf, len, pos) {
if (len === 0) return 0
const startBlockId = Math.floor(pos / blockSize)
// use (pos + len - 1) so an exact block boundary doesn't include the next block
const endBlockId = Math.floor((pos + len - 1) / blockSize)
let copied = 0
for (let blockId = startBlockId; blockId <= endBlockId; blockId++) {
const data = vhd.containsBlock(blockId) ? await enqueueBlock(blockId) : EMPTY_BLOCK
const offsetStart = blockId === startBlockId ? pos % blockSize : 0
const offsetEnd = blockId === endBlockId ? ((pos + len - 1) % blockSize) + 1 : blockSize
data.copy(buf, copied, offsetStart, offsetEnd)
copied += offsetEnd - offsetStart
}
return copied
}
const fuse = new Fuse(mountDir, {
async readdir(path, cb) {
if (path === '/') return cb(null, ['vhd0'])
if (path === '/') {
return cb(null, ['vhd0'])
}
cb(Fuse.ENOENT)
},
async getattr(path, cb) {
if (path === '/') return cb(null, stat({ mode: 'dir', size: 4096 }))
if (path === '/vhd0') return cb(null, stat({ mode: 'file', size: vhd.footer.currentSize }))
if (path === '/') {
return cb(
null,
stat({
mode: 'dir',
size: 4096,
})
)
}
if (path === '/vhd0') {
return cb(
null,
stat({
mode: 'file',
size: vhd.footer.currentSize,
})
)
}
cb(Fuse.ENOENT)
},
read(path, fd, buf, len, pos, cb) {
if (path === '/vhd0') {
return readData(buf, len, pos).then(cb, error => {
return vhd.readRawData(pos, len, cache, buf).then(cb, error => {
warn('read error', { path, len, pos, error })
cb(Fuse.EIO)
})
@@ -131,9 +64,7 @@ export const mount = Disposable.factory(async function* mount(handler, diskPath,
},
})
return new Disposable(
() => {
return fromCallback(cb => fuse.unmount(cb))
},
() => fromCallback(cb => fuse.unmount(cb)),
fromCallback(cb => fuse.mount(cb))
)
})

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/fuse-vhd",
"version": "2.1.3",
"version": "2.1.2",
"license": "ISC",
"private": false,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fuse-vhd",
@@ -19,12 +19,12 @@
},
"main": "./index.mjs",
"dependencies": {
"@xen-orchestra/fs": "^4.9.3",
"@xen-orchestra/fs": "^4.8.0",
"@xen-orchestra/log": "^0.7.2",
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.16.1"
"vhd-lib": "^4.16.0"
},
"bin": {
"xo-fuse-vhd": "./cli.mjs"

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/generator-toolbox",
"version": "1.1.3",
"version": "1.1.2",
"main": "dist/index.mjs",
"license": "MIT",
"private": false,
@@ -10,7 +10,7 @@
"rimraf": "^6.0.1",
"@types/node": "^20.6",
"typescript": "~5.6",
"typescript-eslint": "^8.61.0"
"typescript-eslint": "^8.23.0"
},
"scripts": {
"build": "tsc",

View File

@@ -2,18 +2,8 @@
# @vates/http-server-plus
[![Package Version](https://badgen.net/npm/v/@vates/http-server-plus)](https://npmjs.org/package/@vates/http-server-plus) ![License](https://badgen.net/npm/license/@vates/http-server-plus) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/http-server-plus)](https://bundlephobia.com/result?p=@vates/http-server-plus) [![Node compatibility](https://badgen.net/npm/node/@vates/http-server-plus)](https://npmjs.org/package/@vates/http-server-plus)
> Augmented &#x60;http.Server&#x60;, HTTP/HTTPS/HTTP2 and multiple ports on the same instance
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/http-server-plus):
```sh
npm install --save @vates/http-server-plus
```
## Usage
### `create([opts], [requestListener])`
@@ -58,7 +48,10 @@ await server.listen({ systemdSocket: 0 })
Multiple endpoints can be started on the same instance:
```js
const [httpAddr, httpsAddr] = await Promise.all([server.listen({ port: 80 }), server.listen({ port: 443, cert, key })])
const [httpAddr, httpsAddr] = await Promise.all([
server.listen({ port: 80 }),
server.listen({ port: 443, cert, key }),
])
console.log('listening on', httpAddr, httpsAddr)
```

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/http-server-plus",
"version": "2.0.2",
"version": "2.0.0",
"license": "ISC",
"description": "Augmented `http.Server`, HTTP/HTTPS/HTTP2 and multiple ports on the same instance",
"keywords": [
@@ -27,10 +27,9 @@
},
"scripts": {
"pretest": "eslint .",
"test": "node --test index.spec.js",
"postversion": "npm publish --access public"
"test": "node --test index.spec.js"
},
"private": false,
"private": true,
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"

View File

@@ -19,12 +19,6 @@ export class NbdDisk extends RandomAccessDisk {
/** @type {number} */
#blockSize
/** @type {number | undefined} */
#hasBlockCursor
/** @type {number | undefined} */
#hasBlockPreviousIndex
constructor(nbdInfos, blockSize, { dataMap } = {}) {
super()
this.#blockSize = blockSize
@@ -33,38 +27,11 @@ export class NbdDisk extends RandomAccessDisk {
}
#processDatamap(rawDataMap) {
const ranges = rawDataMap
.filter(({ type, length }) => type === 0 && length > 0)
return rawDataMap
.filter(({ type }) => type === 0)
.map(({ offset, length }) => ({ offset, length }))
.sort(({ offset: offset1 }, { offset: offset2 }) => offset1 - offset2)
// hasBlock()'s forward-only cursor is only correct if the extents are
// sorted AND disjoint: an earlier extent must never reach into a block
// after a later one, otherwise the cursor could skip it and wrongly report
// a block as empty, dropping data and corrupting the output.
// Touching extents (cur.offset === prevEnd) are merged
const merged = []
for (const range of ranges) {
const last = merged[merged.length - 1]
if (last !== undefined) {
const lastEnd = last.offset + last.length
if (range.offset < lastEnd) {
throw new Error(
`overlapping ranges in data map: [${last.offset}, ${lastEnd}) and [${range.offset}, ${range.offset + range.length})`
)
}
if (range.offset === lastEnd) {
// touching: extend the previous extent
last.length += range.length
continue
}
}
// gap (or first range): new extent
merged.push(range)
}
return merged
}
/**
* @param {number} index
* @returns {Promise<DiskBlock>}
@@ -150,34 +117,6 @@ export class NbdDisk extends RandomAccessDisk {
return [...indexes].sort((a, b) => a - b)
}
/**
* Counts the allocated blocks without materializing the index list.
*
* @returns {number}
*/
getBlockIndexesCount() {
if (!this.#dataMap) {
throw new Error("can't getBlockIndexesCount before init")
}
const blockSize = this.getBlockSize()
let count = 0
let lastCountedBlock = -1
for (const { offset, length } of this.#dataMap) {
const firstBlockIndex = Math.floor(offset / blockSize)
const lastBlockIndex = Math.floor((offset + length - 1) / blockSize)
const from = Math.max(firstBlockIndex, lastCountedBlock + 1)
if (lastBlockIndex >= from) {
count += lastBlockIndex - from + 1
lastCountedBlock = lastBlockIndex
}
}
return count
}
/**
* @param {number} index
* @returns {boolean}
@@ -186,32 +125,8 @@ export class NbdDisk extends RandomAccessDisk {
if (!this.#dataMap) {
throw new Error("can't hasBlock before init")
}
const blockSize = this.getBlockSize()
const blockStart = index * blockSize
const blockEnd = blockStart + blockSize
let startExtentIndex = 0
if (this.#hasBlockCursor !== undefined && index >= this.#hasBlockPreviousIndex) {
startExtentIndex = this.#hasBlockCursor
} else {
this.#hasBlockCursor = undefined
}
this.#hasBlockPreviousIndex = index
const dataMap = this.#dataMap
const l = dataMap.length
for (let i = startExtentIndex; i < l; i++) {
const { offset, length } = dataMap[i]
if (offset >= blockEnd) {
// extents are sorted: nothing from here on can overlap this block
break
}
if (offset + length > blockStart) {
this.#hasBlockCursor = i
return true
}
}
return false
const blockStart = index * this.getBlockSize()
const blockEnd = (index + 1) * this.getBlockSize()
return this.#dataMap.some(({ offset, length }) => offset + length > blockStart && offset < blockEnd)
}
}

View File

@@ -1,127 +0,0 @@
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { NbdDisk } from './NbdDisk.mjs'
const KiB = 1024
const MiB = 1024 * KiB
const TiB = 1024 * 1024 * MiB
const SOURCE_BLOCK_SIZE = 2 * MiB // VHD_BLOCK_SIZE, the block size used when importing from VMware
const CLUSTER_SIZE = 64 * KiB // qcow2 cluster size
// hasBlock() only needs the dataMap (passed through the constructor) and the
// block size, so we can exercise it without a live NBD connection.
describe('NbdDisk.hasBlock', () => {
it('reports allocated and unallocated blocks correctly', () => {
const dataMap = [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE }, // block 0
{ type: 0, offset: 5 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 5
]
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
assert.strictEqual(disk.hasBlock(0), true)
assert.strictEqual(disk.hasBlock(1), false)
assert.strictEqual(disk.hasBlock(4), false)
assert.strictEqual(disk.hasBlock(5), true)
assert.strictEqual(disk.hasBlock(6), false)
})
it('handles repeated and out-of-order queries with the resume cursor', () => {
const dataMap = [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE }, // block 0
{ type: 0, offset: 5 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 5
]
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
// repeated indexes (the qcow2 path queries each 2MB block ~32 times as it
// walks 64KB clusters) must return a stable answer
assert.strictEqual(disk.hasBlock(5), true)
assert.strictEqual(disk.hasBlock(5), true)
assert.strictEqual(disk.hasBlock(6), false)
assert.strictEqual(disk.hasBlock(6), false)
// querying backwards must still be correct (falls back to a full scan)
assert.strictEqual(disk.hasBlock(0), true)
assert.strictEqual(disk.hasBlock(5), true)
})
it('invalidates the cursor on a backward miss (no stale skip)', () => {
const dataMap = [
{ type: 0, offset: 3 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 3
{ type: 0, offset: 10 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 10
]
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
assert.strictEqual(disk.hasBlock(10), true) // forward hit: cursor parks on the high extent
assert.strictEqual(disk.hasBlock(1), false) // backward miss: must invalidate the cursor
assert.strictEqual(disk.hasBlock(3), true) // lower extent must not be skipped
})
it('throws on overlapping ranges (would break the resume cursor)', () => {
const dataMap = [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE },
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: SOURCE_BLOCK_SIZE }, // overlaps the previous range
]
assert.throws(() => new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap }), /overlapping ranges/)
})
it('merges touching ranges and drops zero-length / non type-0 entries', () => {
const dataMap = [
{ type: 0, offset: 2 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // [2, 3)
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE / 2 }, // [0, .5)
{ type: 0, offset: SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // [1, 2)
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: SOURCE_BLOCK_SIZE / 2 }, // [.5, 1), touches [0, .5)
{ type: 1, offset: 10 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // wrong type: ignored
{ type: 0, offset: 7 * SOURCE_BLOCK_SIZE, length: 0 }, // zero length: ignored
]
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
assert.strictEqual(disk.hasBlock(0), true)
assert.strictEqual(disk.hasBlock(1), true)
assert.strictEqual(disk.hasBlock(2), true)
assert.strictEqual(disk.hasBlock(3), false)
assert.strictEqual(disk.hasBlock(7), false) // zero-length range dropped
assert.strictEqual(disk.hasBlock(10), false) // non type-0 range dropped
})
})
describe('NbdDisk.getBlockIndexesCount', () => {
const cases = {
'aligned, disjoint blocks': [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE }, // block 0
{ type: 0, offset: 5 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 5
],
'a range spanning several blocks': [
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: 3 * SOURCE_BLOCK_SIZE }, // blocks 0,1,2,3
],
'two disjoint extents sharing block 0 (needs dedup)': [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE / 2 }, // [0, .5) => block 0
{ type: 0, offset: 3 * (SOURCE_BLOCK_SIZE / 4), length: SOURCE_BLOCK_SIZE }, // [.75, 1.75) => blocks 0,1
],
'touching sub-block fragments': [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE / 2 },
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: SOURCE_BLOCK_SIZE / 2 },
{ type: 0, offset: SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE },
],
empty: [],
}
for (const [name, dataMap] of Object.entries(cases)) {
it(`matches getBlockIndexes().length: ${name}`, () => {
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
assert.strictEqual(disk.getBlockIndexesCount(), disk.getBlockIndexes().length)
})
}
it('matches getBlockIndexes().length on the large sparse map', () => {
const virtualSize = Math.floor(3.5 * TiB)
const NB_RANGES = 113528
const dataMap = []
for (let i = 0; i < NB_RANGES; i++) {
dataMap.push({ type: 0, offset: Math.floor((i / NB_RANGES) * virtualSize), length: CLUSTER_SIZE })
}
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
assert.strictEqual(disk.getBlockIndexesCount(), disk.getBlockIndexes().length)
})
})

View File

@@ -13,7 +13,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "3.5.0",
"version": "3.4.0",
"engines": {
"node": ">=14.0"
},
@@ -22,10 +22,10 @@
"@vates/async-each": "^1.0.3",
"@vates/read-chunk": "^1.2.1",
"@xen-orchestra/async-map": "^0.1.3",
"@xen-orchestra/disk-transform": "^1.3.3",
"@xen-orchestra/disk-transform": "^1.2.3",
"@xen-orchestra/log": "^0.7.2",
"promise-toolbox": "^0.21.0",
"xen-api": "^4.8.0"
"xen-api": "^4.7.7"
},
"devDependencies": {
"tap": "^18.7.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@vates/types",
"private": false,
"version": "1.30.1",
"version": "1.24.0",
"main": "./dist/index.mjs",
"exports": {
".": {
@@ -32,8 +32,7 @@
"devDependencies": {
"@eslint/js": "^9.19.0",
"typescript": "~5.6.3",
"typescript-eslint": "^8.61.0",
"@types/express": "^5.0.0"
"typescript-eslint": "^8.23.0"
},
"scripts": {
"build": "tsc",

View File

@@ -1,4 +1,4 @@
import type { XoHost, XoNetwork, XoVif } from './xo.mjs'
import type { XoHost } from './xo.mjs'
declare const __brand: unique symbol
@@ -740,32 +740,3 @@ export type XapiVmStatsRaw = {
export type XapiVmStats = XapiStatsResponse<XapiVmStatsRaw>
export type XapiPoolStats = Record<XoHost['id'], XapiHostStats | { error: Record<string, unknown> }>
export type TrafficRuleProtocol = (typeof TRAFFIC_RULE_PROTOCOLS)[number]
export const SDN_CONTROLLER_OF_RULES_KEY = 'xo:sdn-controller:of-rules'
export const SDN_CONTROLLER_OF_METHOD_KEY = 'xo:sdn-controller:of-method'
export const SDN_CONTROLLER_OF_FORMAT_KEY = 'xo:sdn-controller:of-format'
export const TRAFFIC_RULE_PROTOCOLS = ['ARP', 'ICMP', 'IP', 'TCP', 'UDP'] as const
export const TRAFFIC_RULE_PROTOCOLS_WITH_PORT: readonly TrafficRuleProtocol[] = ['TCP', 'UDP']
export type TrafficRuleTargetType = XoNetwork['type'] | XoVif['type']
export type TrafficRuleDirection = 'from' | 'to' | 'from/to'
export type RawTrafficRule = {
allow: boolean
protocol: string
ipRange: string
direction: string
port?: string
}
type BaseTrafficRule = RawTrafficRule & {
id: string
networkId: XoNetwork['id']
}
export type TrafficRule =
| (BaseTrafficRule & { type: XoVif['type']; sourceId: XoVif['id'] })
| (BaseTrafficRule & { type: XoNetwork['type']; sourceId: XoNetwork['id'] })

View File

@@ -5,4 +5,3 @@ export * from './xo-app.mjs'
export * from './lib/xen-orchestra-xapi.mjs'
export * from './lib/xen-orchestra-acl.mjs'
export * from './lib/complex-matcher.mjs'
export * from './lib/rest-api.mjs'

View File

@@ -1,102 +0,0 @@
import type { NextFunction, Request, Response } from 'express'
import type { VatesTask } from './vates-task.mjs'
import type { XoApp } from '../xo-app.mjs'
export type SecurityName = '*' | 'token' | 'basic' | 'none'
export type FieldDefinition =
| {
type: 'string'
example?: string
optional?: boolean
}
| {
type: 'boolean'
example?: boolean
optional?: boolean
}
| {
type: 'number'
example?: number
optional?: boolean
}
| {
type: 'enum'
enum: string[]
example?: string
optional?: boolean
}
| {
type: 'object'
fields: Record<string, FieldDefinition>
optional?: boolean
}
| {
type: 'array'
items: FieldDefinition
example?: unknown[]
optional?: boolean
}
export type ParamFieldDefinition = Exclude<
FieldDefinition,
{ type: 'boolean' } | { type: 'object' } | { type: 'array' }
>
export type QueryFieldDefinition = Exclude<FieldDefinition, { type: 'object' } | { type: 'array' }>
type RestApi = object & { xoApp: XoApp }
export type CreateAction = <Result>(
callback: (task: VatesTask) => MaybePromise<Result>,
opts: {
sync?: boolean
statusCode?: number
taskProperties: { name: string; [key: string]: unknown }
}
) => Promise<Result | undefined>
export interface BaseRouteDefinition<Middleware> {
method: 'get' | 'post' | 'put' | 'delete' | 'patch'
endpoint: string
description?: string
tags?: string[]
params?: Record<string, ParamFieldDefinition>
query?: Record<string, QueryFieldDefinition>
body?: Record<string, FieldDefinition>
responses?: Array<{
status: number
description: string
schema?: Record<string, FieldDefinition>
}>
middlewares?: Middleware[]
scope?: 'acl'
callback: (params: {
req: Request
res: Response
next: NextFunction
restApi: RestApi
createAction: CreateAction
}) => MaybePromise<unknown>
security?: SecurityName
}
export type MiddlewareDescriptor =
| { name: 'json' | 'urlencoded' | 'text' | 'raw'; options?: Record<string, unknown> }
| { name: 'acl'; acls: LooseAclEntry | LooseAclEntry[] }
type MaybePromise<T> = T | Promise<T>
/**
* Loosely-typed mirror of {@link https://github.com/vatesfr/xen-orchestra/blob/master/@xen-orchestra/rest-api/src/middlewares/acl.middleware.mts | AclEntry}
* from `@xen-orchestra/rest-api/src/middlewares/acl.middleware.mts`.
*/
export type LooseAclEntry = {
resource: string
action?: string | ((opts: { req: object; restApi: RestApi }) => string | undefined)
actions?: string[] | ((opts: { req: object; restApi: RestApi }) => string[])
objectId?: string | ((opts: { req: object; restApi: RestApi }) => string)
objectIds?: string[] | ((opts: { req: object; restApi: RestApi }) => string[])
object?: object | ((opts: { req: object; restApi: RestApi }) => MaybePromise<object> | undefined)
objects?: object[] | ((opts: { req: object; restApi: RestApi }) => MaybePromise<object[]> | undefined)
getObject?: (opts: { restApi: RestApi }) => (id: string) => MaybePromise<object>
}
export type PluginRestRouteDefinition = BaseRouteDefinition<MiddlewareDescriptor>

View File

@@ -1,24 +1,19 @@
import { Branded } from '../common.mjs'
import type { XoGroup, XoUser } from '../xo.mjs'
type XoAclBaseRole = {
id: Branded<'acl-v2-role'>
name: string
description?: string
privilegeIds: XoAclBasePrivilege['id'][]
}
type XoAclRegularRole = XoAclBaseRole & {
groupIds: XoGroup['id'][]
userIds: XoUser['id'][]
}
type XoAclTemplateRole = XoAclBaseRole & {
isTemplate: true
roleTemplateId: number
}
export type XoAclRole = XoAclRegularRole | XoAclTemplateRole
export type XoAclRole =
| {
id: Branded<'acl-v2-role'>
name: string
description?: string
}
| {
id: Branded<'acl-v2-role'>
name: string
description?: string
isTemplate: true
roleTemplateId: number
}
export type XoAclSupportedActionsByResource = {
[resource: string]: Record<string, unknown>

View File

@@ -22,7 +22,6 @@ import type {
VBD_TYPE,
VDI_TYPE,
VIF_LOCKING_MODE,
VM_OPERATIONS,
} from '../common.mjs'
import type { PassThrough, Readable } from 'node:stream'
import type {
@@ -41,64 +40,6 @@ import type {
XoVmSnapshot,
} from '../xo.mjs'
/**
* Properties accepted by {@link Xapi.editVm}.
*
* Field names match the canonical (camelCase) properties defined by the
* underlying `_editVm` (via `makeEditObject`). `_editVm` still resolves the
* snake_case and legacy aliases, but the canonical names are used here.
*/
export interface EditVmProps {
affinityHost?: string | null
autoPoweron?: boolean
blockedOperations?: Partial<Record<VM_OPERATIONS, boolean | string | null>>
coresPerSocket?: number | string | null
cpuCap?: number | null
cpuMask?: number[]
cpuWeight?: number | null
cpus?: number
cpusStaticMax?: number | string
/**
* Update VM creation metadata stored under `other_config.xo:*`. The object is
* merged with the existing data.
*/
creation?: { user?: string }
expNestedHvm?: boolean
hasVendorDevice?: boolean
highAvailability?: 'best-effort' | 'restart' | ''
hvmBootFirmware?: string | null
memory?: number | string
memoryMax?: number | string
memoryMin?: number | string
memoryStaticMax?: number | string
nameDescription?: string
nameLabel?: string
nestedVirt?: boolean
nicType?: string | null
notes?: string | null
PV_args?: string
secureBoot?: boolean
startDelay?: number
suspendSr?: string | null
tags?: string[]
uefiMode?: 'setup' | 'user'
vga?: 'std' | 'cirrus'
videoram?: 1 | 2 | 4 | 8 | 16
viridian?: boolean
virtualizationMode?: 'pv' | 'hvm'
xenStoreData?: Record<string, string | null>
}
/** Properties accepted by {@link Xapi.editVif}. */
export interface EditVifProps {
ipv4Allowed?: string[]
ipv6Allowed?: string[]
lockingMode?: VIF_LOCKING_MODE
// in kB/s, `null` removes the limit
rateLimit?: number | null
txChecksumming?: boolean
}
export type XcpPatches = {
changelog?: {
author: string
@@ -129,12 +70,6 @@ export type XsPatches = {
}
export interface Xapi {
status: string
pool?: { uuid: string }
sessionId: string
_auth: { user: string; password: string }
_url: { protocol: string; hostname: string; hostnameRaw: string; port?: string }
call: <ReturnType>(...args: unknown[]) => Promise<ReturnType>
callAsync: <ReturnType>(...args: unknown[]) => Promise<ReturnType>
@@ -179,13 +114,6 @@ export interface Xapi {
deleteVif(vifId: XoVif['id']): Promise<void>
disconnectVif(vifId: XoVif['id']): Promise<void>
exportVmOva(vmRef: XenApiVm['$ref']): Promise<PassThrough>
emergencyShutdownHost(hostId: XoHost['id']): Promise<void>
ejectHostFromPool(hostId: XoHost['id']): Promise<void>
host_smartReboot(
hostRef: XenApiHost['$ref'],
bypassBlockedSuspend?: boolean,
bypassCurrentVmCheck?: boolean
): Promise<void>
migrateVm(
vmId: XoVm['id'],
hostXapi: Xapi,
@@ -199,7 +127,6 @@ export interface Xapi {
bypassAssert?: boolean
}
): Promise<void>
joinPool(masterAddress: string, masterUsername: string, masterPassword: string, force?: boolean): Promise<void>
listMissingPatches(host: XoHost['id']): Promise<XcpPatches[] | XsPatches[]>
pool_emergencyShutdown(): Promise<void>
resumeVm(id: XoVm['id']): Promise<void>
@@ -229,40 +156,6 @@ export interface Xapi {
}
): Promise<{ vm: XenApiVmWrapped }>
forgetSr(id: XoSr['id']): Promise<void>
forgetHost(hostId: XoHost['id']): Promise<void>
powerOnHost(hostId: XoHost['id']): Promise<void>
rebootHost(hostId: XoHost['id'], force?: boolean): Promise<void>
shutdownHost(hostId: XoHost['id'], opts?: { force?: boolean; bypassEvacuate?: boolean }): Promise<void>
SR_create(params: {
content_type?: XoSr['content_type']
// index signature, not `Record<string, string>`: tsoa emits an empty schema for `Record`
// (https://github.com/lukeautry/tsoa/pull/1858). device_config keys are driver-specific (SR_type).
device_config: {
chapuser?: string
chappassword?: string
device?: string
legacy_mode?: string
location?: string
nfsversion?: string
options?: string
password?: string
SCSIid?: string
server?: string
serverpath?: string
target?: string
targetIQN?: string
username?: string
} & {
[key: string]: string
}
host: XenApiHost['$ref']
name_description?: XoSr['name_description']
name_label: XoSr['name_label']
physical_size?: number
shared: boolean
sm_config?: { [key: string]: string }
type: XoSr['SR_type']
}): Promise<XenApiSr['$ref']>
SR_importVdi(
ref: XenApiSr['$ref'],
stream: Readable,
@@ -281,12 +174,6 @@ export interface Xapi {
}
): Promise<XenApiVdi['$ref']>
SR_reclaimSpace(ref: XenApiSr['$ref']): Promise<void>
editVm(
id: XoVm['id'],
props: EditVmProps,
checkLimits?: (limits: Record<string, number>, vm: XenApiVmWrapped) => Promise<void>
): Promise<void>
editVif(id: XoVif['id'], props: EditVifProps): Promise<void>
startVm(
id: XoVm['id'],
opts?: {
@@ -304,7 +191,7 @@ export interface Xapi {
): Promise<void>
VM_export(
vmRef: XenApiVm['$ref'],
opts?: { cancelToken?: unknown; compress?: boolean | 'zstd' | 'gzip'; useSnapshot?: boolean }
opts?: { cancelToken?: unknown; compress?: boolean; useSnapshot?: boolean }
): ReturnType<Xapi['getResource']>
VM_import(
stream: Readable,
@@ -358,7 +245,6 @@ export interface Xapi {
vgpuType?: XoVgpuType['id']
gpuGroup?: XoGpuGroup['id']
copyHostBiosStrings?: boolean
highAvailability?: XoVm['high_availability']
hvmBootFirmware?: 'uefi' | 'bios'
secureBoot?: boolean
},
@@ -411,7 +297,7 @@ export interface Xapi {
): Promise<Readable & { length?: number }>
VDI_importContent(
vdiRef: XenApiVdi['$ref'],
stream: Readable | Buffer,
stream: Readable,
opts: { cancelToken?: unknown; format: SUPPORTED_VDI_FORMAT }
): Promise<void>
VIF_create(
@@ -450,12 +336,8 @@ export interface Xapi {
pathname: string,
params?: { host?: XenApiHost; query?: Record<string, unknown>; task?: boolean | XenApiTask['$ref'] }
): Promise<{ body: Readable }>
clearHost(
host: Pick<XenApiHostWrapped, '$ref' | '$pool'>,
force?: boolean,
opts?: { transient?: boolean }
): Promise<void>
disableHost(hostId: XoHost['id'], opts?: { transient?: boolean }): Promise<void>
clearHost(host: Pick<XenApiHostWrapped, '$ref' | '$pool'>, force?: boolean): Promise<void>
disableHost(hostId: XoHost['id']): Promise<void>
enableHost(hostId: XoHost['id']): Promise<void>
getRecordByUuid<
Type extends WrappedXenApiRecord['$type'],

View File

@@ -192,7 +192,6 @@ export interface XenApiPool {
ha_host_failures_to_tolerate: number
ha_overcommitted: boolean
ha_plan_exists_for: number
ha_reboot_vm_on_internal_shutdown?: boolean
ha_statefiles: string[]
health_check_config: Record<string, string>
igmp_snooping_enabled?: boolean
@@ -493,7 +492,7 @@ export interface XenApiDrTask {
}
type XenApiHostCallMethods = TagCallMethods & {
<T>(method: 'call_plugin', plugin: string, fn: string, args: Record<string, string>): Promise<T>
<T>(method: 'call_plugin',plugin:string, fn: string, args: Record<string, string>): Promise<T>
}
export interface XenApiHost {
$ref: Branded<'host'>
@@ -571,9 +570,7 @@ export interface XenApiHost {
uuid: string
virtual_hardware_platform_versions: number[]
}
export interface XenApiHostWrapped extends WrapperXenApi<XenApiHost, 'host', XenApiHostCallMethods> {
$restartAgent(): Promise<void>
}
export type XenApiHostWrapped = WrapperXenApi<XenApiHost, 'host', XenApiHostCallMethods>
export interface XenApiHostCrashdump {
$ref: Branded<'host_crashdump'>

View File

@@ -1,5 +1,6 @@
import { EventEmitter } from 'node:stream'
import type {
AnyXoBackupJob,
AnyXoJob,
AnyXoLog,
XapiXoRecord,
@@ -15,12 +16,10 @@ import type {
XoServer,
XoTask,
XoUser,
XoVif,
XoVm,
XoVmBackupArchive,
} from './xo.mjs'
import { VatesTask } from './lib/vates-task.mjs'
import type { PluginRestRouteDefinition } from './lib/rest-api.mjs'
import {
Xapi,
XapiHostStats,
@@ -51,27 +50,12 @@ import {
XoUserRole,
} from './index.mjs'
type FeatureCode =
| 'BACKUP.DELTA'
| 'BACKUP.DELTA_REPLICATION'
| 'BACKUP.FULL'
| 'BACKUP.HEALTHCHECK'
| 'BACKUP.METADATA'
| 'BACKUP.MIRROR'
| 'BACKUP.WITH_RAM'
| 'BACKUP.SMART_BACKUP'
| 'BACKUP.S3'
| 'DOCKER'
| 'EXPORT.XVA'
| 'LIST_MISSING_PATCHES'
| 'POOL_EMERGENCY_SHUTDOWN'
| 'RBAC'
| 'ROLLING_POOL_UPDATE'
| 'ROLLING_POOL_REBOOT'
| 'SMART_REBOOT'
| 'WARM_MIGRATION'
| 'PLUGIN.OPENMETRICS'
export type XapiConnection = Xapi & {
status: string
pool?: { uuid: string }
sessionId: string
_url?: { protocol: string; hostname: string; port?: string }
}
type XapiRecordByXapiXoRecord = {
gpuGroup: XenApiGpuGroupWrapped
host: XenApiHostWrapped
@@ -135,7 +119,7 @@ export type XoApp = {
}
config: {
get<T = string>(path: string): T
getOptional<T = unknown>(path: string): T | undefined
getOptional(path: string): Record<string, string> | undefined
getOptionalDuration(path: string): number | undefined
getGuiRoutes(): Promise<{
default: {
@@ -182,7 +166,6 @@ export type XoApp = {
}
// methods ------------
allocIpAddresses(vifId: XoVif['id'], addAddresses?: string[], removeAddresses?: string[]): Promise<void>
addAclV2GroupRole(groupId: XoGroup['id'], roleId: XoAclRole['id']): Promise<XoGroupRole>
addAclV2UserRole(userId: XoUser['id'], roleId: XoAclRole['id']): Promise<XoUserRole>
addUserToGroup: (userId: XoUser['id'], groupId: XoGroup['id']) => Promise<void>
@@ -195,15 +178,13 @@ export type XoApp = {
params?: any
}
) => () => void // eslint-disable-line @typescript-eslint/no-explicit-any
registerRestRoutes: (routes: PluginRestRouteDefinition[], base?: string) => () => void
authenticateUser: (
credentials: { token?: string; username?: string; password?: string },
userData?: { ip?: string },
opts?: { bypassOtp?: boolean; bypassTaskCreation?: boolean }
) => Promise<{ bypassOtp: boolean; expiration: number; user: XoUser }>
backupGuard(poolId: XoPool['id']): Promise<void>
/* Throw if no authorization */
checkFeatureAuthorization(featureCode: FeatureCode): Promise<void>
checkFeatureAuthorization(featureCode: string): Promise<void>
/* connect a server (XCP-ng/XenServer) */
connectXenServer(id: XoServer['id']): Promise<void>
// TODO: replace all XoAclBasePrivilege with a more strict type. (discriminate union)
@@ -227,54 +208,29 @@ export type XoApp = {
expiresIn?: string | number
userId: XoUser['id']
}): Promise<XoAuthenticationToken>
createRemote(params: {
name: string
options?: string
proxy?: XoProxy['id']
url: string
}): Promise<XoBackupRepository>
createUser(params: { name?: string; password?: string; [key: string]: unknown }): Promise<XoUser>
deleteAclV2GroupRole(
groupId: XoGroup['id'],
roleId: XoAclRole['id'],
opts?: { bypassAuthorization?: boolean }
): Promise<boolean>
deleteAclV2GroupRole(groupId: XoGroup['id'], roleId: XoAclRole['id']): Promise<boolean>
deleteAclV2Privilege(privilegeId: XoAclBasePrivilege['id'], options?: { force?: boolean }): Promise<boolean>
deleteAclV2Role(roleId: XoAclRole['id'], options?: { force?: boolean }): Promise<boolean>
deleteGroup(id: XoGroup['id']): Promise<void>
deleteUser(id: XoUser['id']): Promise<void>
detachHostFromPool(hostId: XoHost['id']): Promise<void>
createGroup(params: { name: string; provider?: string; providerGroup?: string }): Promise<XoGroup>
/* disconnect a server (XCP-ng/XenServer) */
disconnectXenServer(id: XoServer['id']): Promise<void>
findEnabledScheduleSequenceFromSchedule(id: XoSchedule['id']): Promise<XoSchedule | undefined>
getAclV2Privilege(id: XoAclBasePrivilege['id']): Promise<XoAclBasePrivilege>
getAclV2Privileges(): Promise<XoAclBasePrivilege[]>
getAclV2RolePrivileges(roleId: XoAclRole['id']): Promise<XoAclBasePrivilege[]>
getAclV2Role(id: XoAclRole['id'], opts?: { bypassAuthorization?: boolean }): Promise<XoAclRole>
deleteAclV2UserRole(
userId: XoUser['id'],
roleId: XoAclRole['id'],
opts?: { bypassAuthorization?: boolean }
): Promise<boolean>
getAclV2GroupRoles(
groupId: XoGroup['id'],
opts?: { bypassAuthorization?: boolean }
): Promise<Exclude<XoAclRole, { isTemplate: true }>[]>
getAclV2Role(id: XoAclRole['id']): Promise<XoAclRole>
deleteAclV2UserRole(userId: XoUser['id'], roleId: XoAclRole['id']): Promise<boolean>
getAclV2Roles(): Promise<XoAclRole[]>
getAclV2UserPrivileges(userId: XoUser['id']): Promise<XoAclBasePrivilege[]>
getAclV2UserRoles(
userId: XoUser['id'],
opts?: { bypassAuthorization?: boolean; fromGroup?: boolean; fromUser?: boolean }
): Promise<XoAclRole[]>
getAllGroups(): Promise<XoGroup[]>
getAllProxies(): Promise<XoProxy[]>
getAllJobs<T extends AnyXoJob['type']>(type: T): Promise<Extract<AnyXoJob, { type: T }>[]>
getAllJobs<T extends AnyXoBackupJob['type']>(type: T): Promise<Extract<AnyXoBackupJob, { type: T }>[]>
getAllJobs(type?: string): Promise<AnyXoJob[]>
getProxy(id: XoProxy['id']): Promise<XoProxy>
getRemote(id: XoBackupRepository['id']): Promise<XoBackupRepository>
getAllRemotes(): Promise<XoBackupRepository[]>
removeRemote(id: XoBackupRepository['id']): Promise<void>
getAllRemotesInfo(): Promise<
Record<
XoBackupRepository['id'],
@@ -332,7 +288,6 @@ export type XoApp = {
backupRepositoryIds: XoBackupRepository['id'][],
opts?: { _forceRefresh?: boolean; vmId: XoVm['id'] }
): Promise<Record<XoBackupRepository['id'], Record<XoVm['id'], XoVmBackupArchive[]>>>
pingRemote(id: XoBackupRepository['id']): Promise<{ success: true }>
/** Allow to add a new server in the DB (XCP-ng/XenServer) */
registerXenServer(
body: Pick<XoServer, 'host' | 'httpProxy' | 'label' | 'username'> & {
@@ -341,22 +296,11 @@ export type XoApp = {
readOnly?: XoServer['readOnly']
}
): Promise<XoServer>
rollingPoolReboot(pool: XoPool, opts?: { parentTask?: VatesTask; shutdownPinnedVms?: boolean }): Promise<void>
rollingPoolUpdate(
pool: XoPool,
opts?: { rebootVm?: boolean; parentTask?: VatesTask; shutdownPinnedVms?: boolean }
): Promise<void>
setVmResourceSet(vmId: XoVm['id'], resourceSetId: string | null, force?: boolean): Promise<void>
shareVmResourceSet(vmId: XoVm['id']): Promise<void>
rollingPoolReboot(pool: XoPool, opts?: { parentTask?: VatesTask }): Promise<void>
rollingPoolUpdate(pool: XoPool, opts?: { rebootVm?: boolean; parentTask?: VatesTask }): Promise<void>
removeUserFromGroup(userId: XoUser['id'], id: XoGroup['id']): Promise<void>
runJob(job: AnyXoJob, schedule: XoSchedule): void
runWithApiContext: (user: XoUser | undefined, fn: () => void) => Promise<unknown>
testRemote(
id: XoBackupRepository['id']
): Promise<
| { success: true; readRate: number; writeRate: number }
| { success: false; step: string; file: string; error: unknown }
>
/** Remove a server from the DB (XCP-ng/XenServer) */
unregisterXenServer(id: XoServer['id']): Promise<void>
updateUser(
@@ -393,17 +337,7 @@ export type XoApp = {
name?: string
}
): void
updateRemote(
id: XoBackupRepository['id'],
params: {
enabled?: boolean
name?: string
options?: string | null
proxy?: XoProxy['id'] | null
url?: string
}
): Promise<XoBackupRepository>
getAllXapis(): Record<string, Xapi>
getAllXapis(): Record<string, XapiConnection>
getObjects(opts?: { filter?: Record<string, unknown>; limit?: number }): Record<string, XapiXoRecord>
getLicenses(params?: { productType?: LicenseProductType }): Promise<License[]>
bindLicense(params: { licenseId: string; boundObjectId: string }): Promise<License>

View File

@@ -76,7 +76,7 @@ type BaseXoVm = BaseXapiXo & {
expNestedHvm: boolean
isNestedVirtEnabled: boolean
hasVendorDevice: boolean
high_availability: 'best-effort' | 'restart' | ''
high_availability: string
installTime?: number | null
isFirmwareSupported: boolean
memory: {
@@ -222,7 +222,6 @@ export type XoGroup = {
provider?: string
providerGroupId?: string
users: XoUser['id'][]
aclRoleIds: XoAclRole['id'][]
}
export type XoHost = BaseXapiXo & {
@@ -434,7 +433,6 @@ export type XoPool = BaseXapiXo & {
current_operations: Record<string, POOL_ALLOWED_OPERATIONS>
default_SR?: XoSr['id']
HA_enabled: boolean
haRebootVmOnInternalShutdown: boolean
haSrs: XoSr['id'][]
id: Branded<'pool'>
master: XoHost['id']
@@ -566,18 +564,6 @@ export type XoMirrorBackupJob = BaseXoJob & {
export type XoJob = BaseXoJob & {
type: 'call'
method: string
paramsVector?: {
type: string
items: {
type?: string
values?: {
schedules?: XoSchedule['id'][]
[key: string]: unknown
}[]
[key: string]: unknown
}[]
}
}
export type XoSchedule = {
@@ -614,7 +600,7 @@ export type XoSr = BaseXapiXo & {
VDIs: AnyXoVdi['id'][]
allocationStrategy?: 'thin' | 'thick' | 'unknown'
content_type: 'user' | 'iso' | 'disk'
content_type: string
current_operations: Record<string, STORAGE_OPERATIONS>
id: Branded<'SR'>
inMaintenanceMode: boolean
@@ -625,28 +611,7 @@ export type XoSr = BaseXapiXo & {
shared: boolean
size: number
sm_config: Record<string, string>
// SM drivers shipped with XCP-ng/XenServer
SR_type:
| 'cephfs'
| 'dummy'
| 'ext'
| 'file'
| 'glusterfs'
| 'hba'
| 'iso'
| 'largeblock'
| 'linstor'
| 'lvm'
| 'lvmofcoe'
| 'lvmohba'
| 'lvmoiscsi'
| 'moosefs'
| 'nfs'
| 'shm'
| 'smb'
| 'udev'
| 'xfs'
| 'zfs'
SR_type: string
tags: string[]
type: 'SR'
usage: number
@@ -898,6 +863,8 @@ export type NonXapiXoRecord<
| AnyXoLog
| XoGroup
| XoProxy
| XoGroup
| XoProxy
| XoJob
| XoBackupRepository
| XoSchedule

View File

@@ -6,7 +6,7 @@
"main": "dist/index.mjs",
"name": "@xen-orchestra/acl",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/acl",
"version": "1.5.0",
"version": "1.0.0",
"license": "AGPL-3.0-or-later",
"private": false,
"type": "module",
@@ -30,10 +30,10 @@
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@vates/types": "^1.30.1",
"@vates/types": "^1.24.0",
"rimraf": "^6.0.1",
"typescript": "~5.6",
"typescript-eslint": "^8.61.0"
"typescript-eslint": "^8.23.0"
},
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {

View File

@@ -1,13 +1,3 @@
export default {
benchmark: true,
create: true,
read: true,
forget: true,
update: {
enabled: true,
name: true,
options: true,
proxy: true,
url: true,
},
}

View File

@@ -1,27 +1,12 @@
export default {
'allow-vm': true,
detach: true,
disable: true,
enable: true,
evacuate: true,
export: {
logs: true,
},
forget: true,
'join-pool': true,
'migrate-receive': true,
read: true,
reboot: {
clean: true,
smart: true,
},
'restart-toolstack': true,
'scan-pifs': true,
shutdown: {
clean: true,
emergency: true,
},
start: true,
update: {
tags: true,
},

View File

@@ -4,6 +4,5 @@ export default {
read: true,
update: {
tags: true,
other_config: true,
},
}

View File

@@ -1,5 +1,3 @@
export default {
read: true,
plug: true,
unplug: true,
}

View File

@@ -1,5 +1,4 @@
export default {
'add-host': true,
create: {
network: true,
vm: true,

View File

@@ -1,15 +1,10 @@
export default {
create: true,
delete: true,
forget: true,
import: {
vdi: true,
vm: true,
},
'migrate-receive': true,
read: true,
'reclaim-space': true,
scan: true,
update: {
tags: true,
},

View File

@@ -1,7 +1,3 @@
export default {
connect: true,
create: true,
delete: true,
disconnect: true,
read: true,
}

View File

@@ -1,8 +1,3 @@
export default {
delete: true,
export: true,
read: true,
update: {
tags: true,
},
}

View File

@@ -4,12 +4,8 @@ export default {
delete: true,
'export-content': true,
'import-content': true,
'migrate-send': true,
read: true,
update: {
name_description: true,
name_label: true,
size: true,
tags: true,
},
}

View File

@@ -1,15 +1,4 @@
export default {
connect: true,
create: true,
delete: true,
disconnect: true,
read: true,
update: {
allowedIpv4Addresses: true,
allowedIpv6Addresses: true,
lockingMode: true,
rateLimit: true,
txChecksumming: true,
other_config: true,
},
create: true,
}

View File

@@ -1,8 +1,6 @@
export default {
clone: true,
delete: true,
export: true,
'migrate-send': true,
pause: true,
read: true,
reboot: {
@@ -20,42 +18,7 @@ export default {
suspend: true,
unpause: true,
update: {
affinityHost: true,
autoPoweron: true,
blockedOperations: true,
coresPerSocket: true,
cpuCap: true,
cpuMask: true,
cpuWeight: true,
cpus: true,
cpusStaticMax: true,
creation: true,
datasources: true,
expNestedHvm: true,
hasVendorDevice: true,
highAvailability: true,
hvmBootFirmware: true,
memory: true,
memoryMax: true,
memoryMin: true,
memoryStaticMax: true,
nameDescription: true,
nameLabel: true,
nestedVirt: true,
nicType: true,
notes: true,
PV_args: true,
resourceSet: true,
secureBoot: true,
share: true,
startDelay: true,
suspendSr: true,
tags: true,
uefiMode: true,
vga: true,
videoram: true,
viridian: true,
virtualizationMode: true,
xenStoreData: true,
},
}

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/backup-archive",
"version": "1.1.1",
"version": "1.0.0",
"description": "Helper to handle generic backup archives",
"license": "ISC",
"author": "",
@@ -15,34 +15,33 @@
"rimraf": "^6.0.1",
"@types/node": "^22.3.0",
"typescript": "~5.6",
"typescript-eslint": "^8.61.0",
"typescript-eslint": "^8.23.0",
"fs-extra": "^11.1.0",
"sinon": "^18.0.0",
"tar-stream": "^3.1.8",
"@types/tar-stream": "^3.1.4",
"tmp": "^0.2.1",
"@vates/types": "^1.30.1"
"@vates/types": "^1.21.0"
},
"dependencies": {
"@xen-orchestra/log": "^0.7.1",
"@xen-orchestra/fs": "^4.9.3",
"@xen-orchestra/disk-transform": "^1.3.3",
"@xen-orchestra/fs": "^4.6.7",
"@xen-orchestra/disk-transform": "^1.2.3",
"@vates/task": "0.7.0",
"golike-defer": "0.5.1",
"uuid": "^9.0.0",
"promise-toolbox": "^0.21.0",
"@vates/async-each": "^1.0.1",
"vhd-lib": "^4.16.1"
"vhd-lib": "^4.14.7"
},
"scripts": {
"build": "yarn run clean && tsc",
"dev": "tsc --watch",
"postversion": "npm publish --access public",
"test": "yarn run clean && tsc && node --test **/*.test.mjs",
"test-integration": "yarn run clean && tsc && node --test **/*.integ.mjs",
"clean": "rimraf dist/"
},
"private": false,
"private": true,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/backup-archive",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {

View File

@@ -21,7 +21,6 @@ import { limitConcurrency } from 'limit-concurrency-decorator'
import assert from 'node:assert/strict'
const defaultMergeLimiter = limitConcurrency(1)
const MERGE_STATE_FILE_RE = /^\.(.+)\.merge\.json$/
/**
* Tracks the disk chain for a single VDI across all backup snapshots.
* Owns merge and deletion decisions for its chain given which disks are still
@@ -70,14 +69,7 @@ export class RemoteDiskLineage {
* Also detects interrupted merge state files.
*/
async init(): Promise<void> {
let files: string[]
try {
files = await this.#handler.list(this.#vdiDir, { prependDir: true })
} catch (error) {
if (error?.code === 'NOT_SUPPORTED') throw error
this.#opts.logWarn('failed to list VDI directory', { vdiDir: this.#vdiDir, error })
return
}
const files = await this.#handler.list(this.#vdiDir, { prependDir: true })
for (const filePath of files) {
if (isDisk(filePath)) {
@@ -85,23 +77,11 @@ export class RemoteDiskLineage {
}
}
const mergeParentPaths = new Set<string>()
for (const filePath of files) {
const match = MERGE_STATE_FILE_RE.exec(basename(filePath))
if (match !== null) {
mergeParentPaths.add(normalize(this.#vdiDir + '/' + match[1]))
}
}
const uuidToPath = new Map<string, string>()
for (const diskPath of this.#diskPaths) {
let disk: RemoteDisk | undefined
try {
if (mergeParentPaths.has(diskPath)) {
disk = await openDisk({ handler: this.#handler, path: diskPath, force: true })
} else {
disk = await openDisk({ handler: this.#handler, path: diskPath })
}
disk = await openDisk({ handler: this.#handler, path: diskPath })
} catch (error) {
if (error?.code === 'NOT_SUPPORTED' && this.#opts.merge) {
throw error
@@ -309,8 +289,6 @@ export class RemoteDiskLineage {
try {
const disk = await openDisk({ handler: this.#handler, path, ignoreBlockIndexes: true })
await disk.unlink({ force: true })
// Remove from tracking so #cleanOrphanDataFiles skips this now-deleted path
this.#unregisterDisk(path)
} catch (error) {
this.#opts.logWarn('failed to delete unused disk', { path, error })
}
@@ -331,15 +309,8 @@ export class RemoteDiskLineage {
await asyncEach(
toMerge,
async ({ chain, isResuming }) => {
const { finalDiskSize, mergeTargetPath } = await limitedMergeChain([...chain], isResuming)
const { finalDiskSize, mergeTargetPath } = await limitedMergeChain(chain, isResuming)
mergedSizes.set(mergeTargetPath, (mergedSizes.get(mergeTargetPath) ?? 0) + finalDiskSize)
// parentPath alias deleted by parentDisk.rename(mergeTargetPath)
// intermediates deleted by childDisk.unlink() when removeUnused=true
// Unregister so #cleanOrphanDataFiles does not read already-deleted aliases
// and produce false "missing target of alias" warnings.
for (const deletedPath of chain.slice(0, -1)) {
this.#unregisterDisk(deletedPath)
}
},
{ concurrency: this.#opts.mergeConcurrency ?? DEFAULT_MERGE_CONCURRENCY }
)

View File

@@ -10,11 +10,6 @@ export interface PartialBackupMetadata {
jobId: string
scheduleId: string
timestamp: number
// 'true' when written by code including the non-NBD qcow2 corruption fix and its force-full
// gate; its absence marks a potentially-corrupt older backup and makes the gate re-probe.
// Preserved (never stamped) across merges here.
// @todo remove this code and the logic associated on 2027 01 01
includeNonNbdQcow2Fix?: boolean
}
export const DEFAULT_MERGE_CONCURRENCY = 1
export const DEFAULT_REMOVE_CONCURRENCY = 4

View File

@@ -39,10 +39,6 @@ export class VmBackupDirectory implements VmBackupInterface {
#checkResult: (CheckResult & { orphans: string[]; linked: string[] }) | undefined = undefined
#uniqueLineages: Map<string, RemoteDiskLineage> | undefined = undefined
// Set by #checkCacheCount(): the on-disk cache did not match the archives found
// on disk, so clean() must regenerate it even when nothing was merged/removed.
#cacheOutOfSync = false
constructor(
handler: RemoteHandlerAbstract,
vmBackupPath: string,
@@ -187,7 +183,7 @@ export class VmBackupDirectory implements VmBackupInterface {
{ concurrency: 2 }
)
if (allMergedSizes.size > 0 || cacheNeedsRegen || this.#cacheOutOfSync) {
if (allMergedSizes.size > 0 || cacheNeedsRegen) {
await this.#regenerateCache()
}
@@ -218,8 +214,7 @@ export class VmBackupDirectory implements VmBackupInterface {
const existingCache = await this.#readCache(cachePath)
const actual = existingCache === undefined ? 0 : Object.keys(existingCache).length
const expected = this.backupArchives.size
this.#cacheOutOfSync = actual !== expected
if (this.#cacheOutOfSync) {
if (actual !== expected) {
this.opts.logWarn('unexpected number of entries in backup cache', { path: cachePath, actual, expected })
}
}

View File

@@ -101,7 +101,8 @@ describe('VmBackupDirectory with full backups', { concurrency: 1 }, () => {
await createFullBackupMetadata('backup1.json', 'backup1.xva')
await handler.writeFile(`${rootPath}/orphan.xva`, 'orphan-content')
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true })
await vmBackupDir.init()
await vmBackupDir.clean({ remove: true })
const remainingFiles = await handler.list(rootPath)
assert.ok(remainingFiles.includes('backup1.json'))
@@ -109,24 +110,12 @@ describe('VmBackupDirectory with full backups', { concurrency: 1 }, () => {
assert.ok(!remainingFiles.includes('orphan.xva'))
})
test('Missing XVA cleans the metadata', async () => {
await createFullBackupMetadata('metadata.json', 'missing.xva')
await handler.unlink(`${rootPath}/missing.xva`)
let logged = ''
const logInfo = message => {
logged += message + '\n'
}
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, logInfo, logWarn: logInfo })
const remainingFiles = await handler.list(rootPath)
assert.ok(logged.match(/XVA is missing/g))
assert.ok(!remainingFiles.includes('metadata.json'))
})
test('clean() preserves cache.json.gz', async () => {
await createFullBackupMetadata('backup1.json', 'backup1.xva')
await handler.writeFile(`${rootPath}/cache.json.gz`, 'cache')
await VmBackupDirectory.cleanVm(handler, rootPath)
await vmBackupDir.init()
await vmBackupDir.clean()
const remainingFiles = await handler.list(rootPath)
assert.equal(remainingFiles.length, 3)
@@ -136,7 +125,8 @@ describe('VmBackupDirectory with full backups', { concurrency: 1 }, () => {
test('clean() removes an orphan XVA checksum file (no matching metadata or XVA)', async () => {
await handler.writeFile(`${rootPath}/stray.xva.checksum`, 'sha256:deadbeef')
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true })
await vmBackupDir.init()
await vmBackupDir.clean({ remove: true })
const remainingFiles = await handler.list(rootPath)
assert.ok(!remainingFiles.includes('stray.xva.checksum'), 'orphan XVA checksum should be deleted')

View File

@@ -43,19 +43,12 @@ const isValidTar = async (handler: RemoteHandlerAbstract, size: number, fd: File
return buf.every(byte => byte === 0)
}
export async function isValidXva(
handler: RemoteHandlerAbstract,
filePath: string,
metadataSize?: number
): Promise<boolean> {
export async function isValidXva(handler: RemoteHandlerAbstract, filePath: string): Promise<boolean> {
// size is longer when encrypted + reading part of an encrypted file is not implemented
if (handler.isEncrypted) {
const size = await handler.getSizeOnDisk(filePath)
return size > 20
return true
}
let isValid = true
const fd = await handler.openFile(filePath, 'r')
try {
const size = await handler.getSize(filePath)
@@ -63,19 +56,13 @@ export async function isValidXva(
// neither a valid gzip nor tar
return false
}
let validSize = true
if (metadataSize !== undefined) {
validSize = metadataSize === size
}
const validDisk = (await isCompressedFile(handler, fd))
return (await isCompressedFile(handler, fd))
? true // compressed files cannot be validated at this time
: await isValidTar(handler, size, fd)
isValid = validSize && validDisk
} finally {
handler.closeFile(fd).catch(noop)
}
return isValid
}
const noop = (): void => {}
@@ -114,29 +101,31 @@ export class VmFullBackupArchive implements VmBackupInterface {
*/
async check(): Promise<CheckResult> {
if (this.isValid === undefined) {
let fileSize = 0
let validDisk = false
let validSize = true
try {
this.isValid = await isValidXva(this.handler, this.xvaPath, this.metadata.size)
fileSize = await this.handler.getSize(this.xvaPath)
validDisk = await isValidXva(this.handler, this.xvaPath)
} catch (error) {
this.isValid = false
validDisk = false
if (error?.code === 'ENOENT') {
this.missingDisk = true
}
this.opts.logWarn('Issue while checking XVA', { error })
}
if (!this.handler.isEncrypted) {
try {
await this.handler.getSize(`${this.xvaPath}.checksum`)
} catch (error) {
this.opts.logWarn('Checksum file not valid, not blocking', { error })
}
try {
await this.handler.getSize(`${this.xvaPath}.checksum`)
} catch (error) {
this.opts.logWarn('Checksum file not valid, not blocking', { error })
}
if (this.metadata.size !== undefined) {
validSize = this.metadata.size === fileSize
}
this.isValid = fileSize > 0 && validSize && validDisk
}
if (!this.isValid) {
if (this.missingDisk) {
this.opts.logWarn('XVA is missing', { path: this.xvaPath })
} else {
this.opts.logWarn('XVA might be broken', { path: this.xvaPath })
}
this.opts.logWarn('XVA might be broken', { path: this.xvaPath })
}
return { isValid: this.isValid }
}

View File

@@ -8,19 +8,13 @@ import { getHandler } from '@xen-orchestra/fs'
import { pFromCallback } from 'promise-toolbox'
// eslint-disable-next-line n/no-missing-import
import { VmBackupDirectory } from '../dist/VmBackupDirectory.mjs'
import { RemoteVhdDisk } from '../dist/disks/RemoteVhdDisk.mjs'
import { MergeRemoteDisk } from '../dist/disks/MergeRemoteDisk.mjs'
import { VHDFOOTER, VHDHEADER } from './tests.fixtures.mjs'
import { VhdFile, Constants, VhdDirectory, VhdAbstract } from 'vhd-lib'
import { dirname, basename } from 'node:path'
import { promisify } from 'node:util'
import zlib from 'node:zlib'
import { rimraf } from 'rimraf'
const { beforeEach, afterEach, describe } = test
const gunzip = promisify(zlib.gunzip)
let tempDir, handler, jobId, vdiId, basePath, relativePath, vdiId2, basePath2, relativePath2
const rootPath = 'xo-vm-backups/VMUUID/'
@@ -418,118 +412,6 @@ test('it merges a chain of multiple consecutive orphan ancestors in one pass', a
assert.equal(remainingVhds.includes('child.vhd'), false)
})
test('it resumes, through cleanVm, an interrupted merge whose ancestor lost its footer', async () => {
// Reproduces the production "footer1 !== footer2" failure end-to-end via the full cleanVm
// pipeline. ancestor (full) <- child (differencing, the active backup).
const ancestor = await generateVhd(`${basePath}/ancestor.vhd`, { blocks: [0, 1] })
await generateVhd(`${basePath}/child.vhd`, {
header: { parentUnicodeName: 'ancestor.vhd', parentUuid: ancestor.footer.uuid },
blocks: [2, 3, 4, 5],
})
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({ mode: 'delta', size: 12000, vhds: [`${relativePath}/child.vhd`] })
)
// Simulate a merge killed mid-transfer: write 2 of the 4 child blocks into the ancestor
// (which overwrites its end footer) then throw before mergeMetadata() rewrites the footer.
// This leaves the ancestor footerless AND a `.merge.json` state file — the exact post-crash
// state that makes cleanVm take the resume path.
const parent = new RemoteVhdDisk({ handler, path: `${basePath}/ancestor.vhd` })
const child = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd` })
await parent.init({ force: false })
await child.init({ force: false })
const merger = new MergeRemoteDisk(handler, { removeUnused: true, writeStateDelay: 0 })
let written = 0
const realWriteBlock = parent.writeBlock.bind(parent)
parent.writeBlock = async diskBlock => {
if (written === 2) {
throw new Error('simulated interruption')
}
written++
return realWriteBlock(diskBlock)
}
await assert.rejects(merger.merge(parent, child), /simulated interruption/)
assert.ok(
(await handler.list(basePath)).includes('.ancestor.vhd.merge.json'),
'an interrupted-merge state file should remain'
)
// Full pipeline: cleanVm must recognize the interrupted merge and resume it — opening the
// footerless ancestor through the chain without throwing "footer1 !== footer2".
await VmBackupDirectory.cleanVm(handler, rootPath, {
remove: true,
merge: true,
logInfo: () => {},
logWarn: () => {},
})
// The merge completed: the ancestor was consolidated into child.vhd and the state file is gone.
const remaining = await handler.list(basePath)
assert.equal(remaining.includes('.ancestor.vhd.merge.json'), false, 'state file removed after successful resume')
assert.equal(remaining.includes('ancestor.vhd'), false, 'ancestor consolidated into the merge target')
assert.equal(remaining.includes('child.vhd'), true, 'merge target remains')
// No blocks lost: the resumed merge wrote the blocks (4, 5) that were never written before
// the crash, so the consolidated disk holds the full set.
const merged = new RemoteVhdDisk({ handler, path: `${basePath}/child.vhd` })
await merged.init({ force: false })
try {
assert.deepEqual(merged.getBlockIndexes(), [0, 1, 2, 3, 4, 5], 'all blocks present after resumed merge')
} finally {
await merged.close()
}
// The backup is consistent, so its metadata is preserved.
assert.ok((await handler.list(rootPath)).includes('metadata.json'), 'metadata should be preserved')
})
test('it does not warn "missing target of alias" for aliases deleted by merge', async () => {
// Regression: RemoteDiskLineage.#cleanOrphanDataFiles iterated #diskPaths entries just
// deleted during the same clean() call's merge phase, producing false "missing target of
// alias" warnings
const orphan = await generateVhd(`${basePath}/orphan.vhd`, {
useAlias: true,
blocks: [0],
})
await generateVhd(`${basePath}/child.vhd`, {
useAlias: true,
header: {
parentUnicodeName: 'orphan.vhd.alias.vhd',
parentUuid: orphan.footer.uuid,
},
blocks: [1],
})
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [`${relativePath}/child.vhd.alias.vhd`],
})
)
const warnings = []
await VmBackupDirectory.cleanVm(handler, rootPath, {
remove: true,
merge: true,
logWarn: (message, data) => warnings.push({ message, data }),
logInfo: () => {},
})
const missingTargetWarnings = warnings.filter(w => w.message === 'missing target of alias')
assert.equal(
missingTargetWarnings.length,
0,
`"missing target of alias" must not fire after merge; got: ${JSON.stringify(missingTargetWarnings)}`
)
const remaining = await handler.list(basePath)
assert.ok(!remaining.includes('orphan.vhd.alias.vhd'), 'orphan alias must be deleted after merge')
assert.ok(remaining.includes('child.vhd.alias.vhd'), 'child alias must survive as merge target')
})
test('it resumes an interrupted merge with chain field', async () => {
await handler.writeFile(
`${rootPath}/metadata.json`,
@@ -804,56 +686,3 @@ test('check all types of aliases, corrupted, missing or not', async () => {
assert.equal(data.length, 1)
assert.equal(data[0], 'ok.vhd')
})
test('it preserves VDI directory when handler.list() throws during lineage init', async () => {
// Regression: when handler.list(vdiDir) threw (e.g. S3 404), the lineage
// was never added to #uniqueLineages, so cleanOrphanDiskDirs treated the directory
// as an orphan and deleted it with rmtree()
await generateVhd(`${basePath}/snapshot.vhd`)
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({ mode: 'delta', vhds: [`${relativePath}/snapshot.vhd`] })
)
const originalList = handler.list.bind(handler)
handler.list = async (path, opts) => {
if (typeof path === 'string' && path.includes(vdiId)) {
throw Object.assign(new Error('Simulated transient storage error'), { code: 'ENOENT' })
}
return originalList(path, opts)
}
try {
await VmBackupDirectory.cleanVm(handler, rootPath, { remove: true, logWarn: () => {}, logInfo: () => {} })
} finally {
handler.list = originalList
}
const remaining = await handler.list(basePath)
assert.ok(remaining.includes('snapshot.vhd'), 'VHD must survive when handler.list() throws during lineage init')
})
test('it regenerates the cache when its entry count is out of sync, even with nothing to merge/remove', async () => {
// a referenced disk + its metadata, but no cache.json.gz on disk
await generateVhd(`${basePath}/diskA1.vhd`)
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({ mode: 'delta', vhds: [`${relativePath}/diskA1.vhd`] })
)
await assert.rejects(handler.readFile(`${rootPath}/cache.json.gz`), { code: 'ENOENT' })
const logged = []
// remove/merge both false: cacheNeedsRegen stays false and no merge happens,
// so only the cache-count mismatch can trigger the regeneration
await VmBackupDirectory.cleanVm(handler, rootPath, {
remove: false,
merge: false,
logInfo: () => {},
logWarn: message => logged.push(message),
})
assert.ok(logged.includes('unexpected number of entries in backup cache'), 'mismatch should be reported')
const cache = JSON.parse((await gunzip(await handler.readFile(`${rootPath}/cache.json.gz`))).toString())
assert.equal(Object.keys(cache).length, 1, 'cache should be regenerated to match the archives on disk')
})

View File

@@ -52,18 +52,18 @@ export class VmIncrementalBackupArchive implements VmBackupInterface {
async init(): Promise<void> {
// Build one RemoteDiskLineage per VDI directory derived from diskPaths
for (const diskPath of this.diskPaths) {
const vdiDir = dirname(diskPath)
if (!this.diskLineages.has(vdiDir)) {
const lineage = new RemoteDiskLineage(this.handler, vdiDir, this.opts)
this.diskLineages.set(vdiDir, lineage)
try {
try {
for (const diskPath of this.diskPaths) {
const vdiDir = dirname(diskPath)
if (!this.diskLineages.has(vdiDir)) {
const lineage = new RemoteDiskLineage(this.handler, vdiDir, this.opts)
await lineage.init()
} catch (error) {
if (error?.code === 'NOT_SUPPORTED') throw error
this.opts.logWarn('failed to scan VDI directory', { vdiDir, error })
this.diskLineages.set(vdiDir, lineage)
}
}
} catch (error) {
if (error?.code === 'NOT_SUPPORTED') throw error
this.opts.logWarn('failed to scan VDI directories', { error })
}
}

View File

@@ -142,11 +142,10 @@ export class MergeRemoteDisk {
const mergeStateContent = await this.#handler.readFile(this.#statePath)
this.#state = JSON.parse(mergeStateContent)
// work-around to bugs introduced in 97d94b795 and 4e50858
// work-around a bug introduce in 97d94b795
//
// currentBlock could be `null` due to the JSON.stringify of a `NaN` value
// could also be -1 due to being set as Math.min(...merging) - 1 if merging contains 0
if (this.#state?.currentBlock === null || this.#state?.currentBlock < 0) this.#state.currentBlock = 0
if (this.#state?.currentBlock === null) this.#state.currentBlock = 0
this.#isResuming = true
} catch (error) {
// @ts-ignore
@@ -256,7 +255,7 @@ export class MergeRemoteDisk {
const blockSize = await parentDisk.mergeBlock(childDisk, blockId, this.#isResuming)
this.#state.mergedDataSize += blockSize
this.#state.currentBlock = Math.max(0, Math.min(...merging) - 1)
this.#state.currentBlock = Math.min(...merging) - 1
merging.delete(blockId)
this.#onProgress({ total: nBlocks, done: counter + 1 })

View File

@@ -972,53 +972,6 @@ describe('tests MergeRemoteDisk', { concurrency: 1 }, () => {
assert.deepEqual(parent2.getBlockIndexes(), [0, 1, 2, 3], 'all blocks from the chain should be merged')
})
test('mergeRemoteDisk resumes when state has currentBlock = -1', async () => {
// If the merge fails it will try to resume the merge at the lowest merging block - 1,
// if block 0 was being merged at this time, the state file currentBlock will be set to -1
// which is not a valid block id.
const parentVhdPath = `${basePath}/parent.vhd`
await generateVhd(parentVhdPath, { blocks: [1] })
const parent = new RemoteVhdDisk({ handler, path: parentVhdPath })
await parent.init({ force: false })
const childVhdPath = `${basePath}/child.vhd`
await generateVhd(childVhdPath, { blocks: [0] })
const child = new RemoteVhdDisk({ handler, path: childVhdPath })
await child.init({ force: false })
// Fail after asyncEach completes
parent.flushMetadata = async () => {
throw new Error('simulated interruption')
}
const mergeRemoteDisk = new MergeRemoteDisk(handler, { writeStateDelay: 0 })
let threw = false
try {
await mergeRemoteDisk.merge(parent, child)
} catch (err) {
threw = true
assert.equal(err.message, 'simulated interruption')
}
assert.ok(threw, 'first merge attempt should throw on simulated interruption')
const stateFileName = `.parent.vhd.merge.json`
assert.ok((await handler.list(basePath)).includes(stateFileName), 'state file should exist after failure')
// Resume with fresh instances
const parent2 = new RemoteVhdDisk({ handler, path: parentVhdPath })
await parent2.init({ force: true })
const child2 = new RemoteVhdDisk({ handler, path: childVhdPath })
await child2.init({ force: true })
const mergeRemoteDisk2 = new MergeRemoteDisk(handler, { removeUnused: false })
await mergeRemoteDisk2.merge(parent2, child2)
const indexes = parent2.getBlockIndexes()
assert.ok(indexes.includes(0), 'block 0 from child should be merged into parent')
assert.ok(indexes.includes(1), 'block 1 from parent should remain')
})
test('mergeRemoteDisk merge a bigger child into a parent', async () => {
const parentVhdPath = `${basePath}/parent.vhd`
await generateVhd(parentVhdPath, { blocks: [0, 1] })

View File

@@ -312,12 +312,4 @@ export class RemoteVhdDiskChain extends RemoteDisk {
async clean(opts = {}) {
throw new Error(`Can't clean a disk chain`)
}
/**
*
* @returns {RemoteVhdDisk|undefined}
*/
getRootDisk() {
return this.#disks[0]
}
}

View File

@@ -93,10 +93,14 @@ export async function cleanOrphanDiskDirs(
}
for (const vdiDir of vdiDirs) {
if (!coveredDirs.has(vdiDir)) {
// TODO: readd logWarn when rmtree() is added below
logWarn('orphan VDI directory, not referenced by any backup', { path: vdiDir })
if (remove) {
logInfo('you could delete orphan VDI directory', { path: vdiDir })
// TODO: Readd rmtree() when codebase is ready
logInfo('deleting orphan VDI directory', { path: vdiDir })
try {
await handler.rmtree(vdiDir)
} catch (error) {
logWarn('failed to delete orphan VDI directory', { path: vdiDir, error })
}
}
}
}

View File

@@ -15,10 +15,9 @@ import { RemoteVhdDiskChain } from './RemoteVhdDiskChain.mjs'
* @param {string} params.path
* @param {string | undefined} params.until
* @param {boolean} [params.force]
* @param {boolean} [params.ignoreBlockIndexes] - skip reading the block allocation table of each disk (cheaper, but blocks can't be read)
* @returns {Promise<RemoteDisk>}
*/
async function _openDiskChain($defer, { handler, path, until, force = false, ignoreBlockIndexes = false }) {
async function _openDiskChain($defer, { handler, path, until, force = false }) {
/**
* @type {RemoteVhdDisk}
*/
@@ -30,18 +29,16 @@ async function _openDiskChain($defer, { handler, path, until, force = false, ign
$defer.onFailure(() => Promise.all(disks.map(disk => disk.close())))
disk = new RemoteVhdDisk({ handler, path })
await disk.init({ force, ignoreBlockIndexes })
await disk.init({ force })
disks.push(disk)
let foundRootDisk = until === undefined
while (disk.isDifferencing()) {
if (disk.getParentPath() === until) {
disk = await disk.openParent()
if (disk.getPath() === until) {
foundRootDisk = true
await disk.close()
break
}
// Instantiate + init the parent directly (instead of openParent()) so the
// block allocation table read can be skipped when ignoreBlockIndexes is set.
disk = /** @type {RemoteVhdDisk} */ (disk.instantiateParent())
await disk.init({ force, ignoreBlockIndexes })
disks.unshift(disk)
}
if (!foundRootDisk) {
@@ -52,6 +49,6 @@ async function _openDiskChain($defer, { handler, path, until, force = false, ign
}
/**
* @type {(params: { handler: RemoteHandlerAbstract, path: string, until?: string, force?: boolean, ignoreBlockIndexes?: boolean }) => Promise<RemoteDisk>}
* @type {(params: { handler: RemoteHandlerAbstract, path: string, until?: string, force?: boolean }) => Promise<RemoteDisk>}
*/
export const openDiskChain = defer(_openDiskChain)

View File

@@ -8,8 +8,8 @@
"dependencies": {
"@vates/async-each": "^1.0.3",
"@xen-orchestra/async-map": "^0.1.3",
"@xen-orchestra/backups": "^0.73.10",
"@xen-orchestra/fs": "^4.9.3",
"@xen-orchestra/backups": "^0.72.1",
"@xen-orchestra/fs": "^4.8.0",
"filenamify": "^6.0.0",
"getopts": "^2.2.5",
"lodash": "^4.18.0",
@@ -28,7 +28,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "1.1.13",
"version": "1.1.11",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -1,23 +1,34 @@
import { asyncEach } from '@vates/async-each'
import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
import { compose } from '@vates/compose'
import { createLogger } from '@xen-orchestra/log'
import { VhdDirectory, VhdSynthetic } from 'vhd-lib'
import { decorateMethodsWith } from '@vates/decorate-with'
import { deduped } from '@vates/disposable/deduped.js'
import { dirname, join, resolve } from 'node:path'
import { execFile } from 'child_process'
import { mount } from '@vates/fuse-vhd'
import { readdir, lstat } from 'node:fs/promises'
import { synchronized } from 'decorator-synchronized'
import { ZipFile } from 'yazl'
import Disposable from 'promise-toolbox/Disposable'
import fromCallback from 'promise-toolbox/fromCallback'
import fromEvent from 'promise-toolbox/fromEvent'
import groupBy from 'lodash/groupBy.js'
import pDefer from 'promise-toolbox/defer'
import pickBy from 'lodash/pickBy.js'
import reduce from 'lodash/reduce.js'
import * as tar from 'tar'
import zlib from 'zlib'
import { BACKUP_DIR } from './_getVmBackupDir.mjs'
import { VmBackupDirectory } from '@xen-orchestra/backup-archive'
import { fileRestoreDecorators, fileRestoreMethods } from './_fileRestore.mjs'
import { formatFilenameDate } from './_filenameDate.mjs'
import { getTmpDir } from './_getTmpDir.mjs'
import { isMetadataFile } from './_backupType.mjs'
import { isValidXva } from './_isValidXva.mjs'
import { listPartitions, LVM_PARTITION_TYPE_MBR, LVM_PARTITION_TYPE_GPT } from './_listPartitions.mjs'
import { lvs, pvs } from './_lvm.mjs'
import { watchStreamSize } from './_watchStreamSize.mjs'
import { RemoteVhdDisk, openDiskChain } from '@xen-orchestra/backup-archive/disks'
@@ -37,6 +48,25 @@ export const compareTimestamp = (a, b) => a.timestamp - b.timestamp
const noop = Function.prototype
const resolveRelativeFromFile = (file, path) => resolve('/', dirname(file), path).slice(1)
const makeRelative = path => resolve('/', path).slice(1)
const resolveSubpath = (root, path) => resolve(root, makeRelative(path))
async function addZipEntries(zip, realBasePath, virtualBasePath, relativePaths) {
for (const relativePath of relativePaths) {
const realPath = join(realBasePath, relativePath)
const virtualPath = join(virtualBasePath, relativePath)
const stats = await lstat(realPath)
const { mode, mtime } = stats
const opts = { mode, mtime }
if (stats.isDirectory()) {
zip.addEmptyDirectory(virtualPath, opts)
await addZipEntries(zip, realPath, virtualPath, await readdir(realPath))
} else if (stats.isFile()) {
zip.addFile(realPath, virtualPath, opts)
}
}
}
const createSafeReaddir = (handler, methodName) => (path, options) =>
handler.list(path, options).catch(error => {
@@ -46,6 +76,11 @@ const createSafeReaddir = (handler, methodName) => (path, options) =>
return []
})
const debounceResourceFactory = factory =>
function () {
return this._debounceResource(factory.apply(this, arguments))
}
export class RemoteAdapter {
constructor(
handler,
@@ -63,6 +98,112 @@ export class RemoteAdapter {
return this._handler
}
async _findPartition(devicePath, partitionId) {
const partitions = await listPartitions(devicePath)
const partition = partitions.find(_ => _.id === partitionId)
if (partition === undefined) {
throw new Error(`partition ${partitionId} not found`)
}
return partition
}
async *_getLvmLogicalVolumes(devicePath, pvId, vgName) {
yield this._getLvmPhysicalVolume(devicePath, pvId && (await this._findPartition(devicePath, pvId)))
debug('activate LVM volume group', { vgName })
await fromCallback(execFile, 'vgchange', ['-ay', vgName])
try {
debug('get LVM volume group name and path', { vgName })
yield lvs(['lv_name', 'lv_path'], vgName)
} finally {
debug('deactivate LVM volume group', { vgName })
await fromCallback(execFile, 'vgchange', ['-an', vgName])
}
}
async *_getLvmPhysicalVolume(devicePath, partition) {
const args = []
if (partition !== undefined) {
args.push('-o', partition.start * 512, '--sizelimit', partition.size)
}
args.push('--show', '-f', devicePath)
debug('attach loop device', { devicePath, partition })
const path = (await fromCallback(execFile, 'losetup', args)).trim()
try {
debug('list LVM physical volume', { path })
await fromCallback(execFile, 'pvscan', ['--cache', path])
yield path
} finally {
try {
const vgNames = await pvs('vg_name', path)
debug('deactivate LVM volume groups', { vgNames })
await fromCallback(execFile, 'vgchange', ['-an', ...vgNames])
} finally {
debug('detach loop device', { path })
await fromCallback(execFile, 'losetup', ['-d', path])
}
}
}
async *_getPartition(devicePath, partition) {
// the norecovery option is necessary because if the partition is dirty,
// mount will try to fix it which is impossible if because the device is read-only
const options = ['loop', 'ro', 'norecovery']
if (partition !== undefined) {
const { size, start } = partition
options.push(`sizelimit=${size}`)
if (start !== undefined) {
options.push(`offset=${start * 512}`)
}
}
const path = yield getTmpDir()
const mount = options => {
debug('mount device', { devicePath, mountPath: path })
return fromCallback(execFile, 'mount', [
`--options=${options.join(',')}`,
`--source=${devicePath}`,
`--target=${path}`,
])
}
// `norecovery` option is used for ext3/ext4/xfs, if it fails it might be
// another fs, try without
try {
await mount([...options, 'norecovery'])
} catch (error) {
await mount(options)
}
try {
yield path
} finally {
debug('umount device', { devicePath, mountPath: path })
await fromCallback(execFile, 'umount', ['--lazy', path])
}
}
_listLvmLogicalVolumes(devicePath, partition, results = []) {
return Disposable.use(this._getLvmPhysicalVolume(devicePath, partition), async path => {
const lvs = await pvs(['lv_name', 'lv_path', 'lv_size', 'vg_name'], path)
const partitionId = partition !== undefined ? partition.id : ''
lvs.forEach((lv, i) => {
const name = lv.lv_name
if (name !== '') {
results.push({
id: `${partitionId}/${lv.vg_name}/${name}`,
name,
size: lv.lv_size,
})
}
})
return results
})
}
// check if we will be allowed to merge a vhd created in this adapter
// with the vhd at path `path`
async isMergeableParent(packedParentUid, path) {
@@ -80,6 +221,34 @@ export class RemoteAdapter {
})
}
fetchPartitionFiles(diskId, partitionId, paths, format) {
const { promise, reject, resolve } = pDefer()
Disposable.use(
async function* () {
const path = yield this.getPartition(diskId, partitionId)
let outputStream
if (format === 'tgz') {
outputStream = tar.c({ cwd: path, gzip: true }, paths.map(makeRelative))
} else if (format === 'zip') {
const zip = new ZipFile()
await addZipEntries(zip, path, '', paths.map(makeRelative))
zip.end()
;({ outputStream } = zip)
} else {
throw new Error('unsupported format ' + format)
}
resolve(outputStream)
await fromEvent(outputStream, 'end')
}.bind(this)
).catch(error => {
warn(error)
reject(error)
})
return promise
}
async #removeVmBackupsFromCache(backups) {
await asyncEach(
Object.entries(
@@ -131,20 +300,7 @@ export class RemoteAdapter {
async deleteFullVmBackups(backups) {
const handler = this._handler
await asyncMapSettled(backups, ({ _filename, xva }) =>
Promise.all([
handler.unlink(_filename).catch(error => {
warn('error while removing full vm backup metadata', { error, filename: _filename })
if (error.code !== 'ENOENT') throw error
}),
handler.unlink(resolveRelativeFromFile(_filename, xva)).catch(error => {
warn('error while removing full vm backup file', { error, filename: _filename })
if (error.code !== 'ENOENT') throw error
}),
handler.unlink(resolveRelativeFromFile(_filename, `${xva}.checksum`)).catch(error => {
// checksum can be missing , it's not an issue
if (error.code !== 'ENOENT') throw error
}),
])
Promise.all([handler.unlink(_filename), handler.unlink(resolveRelativeFromFile(_filename, xva))])
)
await this.#removeVmBackupsFromCache(backups)
@@ -155,46 +311,18 @@ export class RemoteAdapter {
}
async deleteVmBackups(files) {
const metadataOrNull = await asyncMap(files, async file => {
try {
return await this.readVmBackupMetadata(file)
} catch (error) {
if (error.code === 'ENOENT') {
// File was already removed (e.g. by coalescing); clean the stale cache entry
warn('backup metadata not found, removing stale cache entry', { file })
return null
}
throw error
}
})
const presentMetadata = []
const missingFiles = []
for (let i = 0; i < files.length; i++) {
if (metadataOrNull[i] === null) {
missingFiles.push({ _filename: files[i] })
} else {
presentMetadata.push(metadataOrNull[i])
}
}
const { delta, full, ...others } = groupBy(presentMetadata, 'mode')
const metadata = await asyncMap(files, file => this.readVmBackupMetadata(file))
const { delta, full, ...others } = groupBy(metadata, 'mode')
const unsupportedModes = Object.keys(others)
if (unsupportedModes.length !== 0) {
throw new Error('no deleter for backup modes: ' + unsupportedModes.join(', '))
}
const promises = []
if (delta !== undefined) {
promises.push(this.deleteDeltaVmBackups(delta))
}
if (full !== undefined) {
promises.push(this.deleteFullVmBackups(full))
}
if (missingFiles.length) {
promises.push(this.#removeVmBackupsFromCache(missingFiles))
}
await Promise.all(promises)
await Promise.all([
delta !== undefined && this.deleteDeltaVmBackups(delta),
full !== undefined && this.deleteFullVmBackups(full),
])
await asyncMap(new Set(files.map(file => dirname(file))), dir =>
// - don't merge in main process, unused VHDs will be merged in the next backup run
@@ -217,6 +345,76 @@ export class RemoteAdapter {
return this.useVhdDirectory()
}
async *#getDiskLegacy(diskId) {
const RE_VHDI = /^vhdi(\d+)$/
const handler = this._handler
const diskPath = handler.getFilePath('/' + diskId)
const mountDir = yield getTmpDir()
debug('mount VHD (vhdimount)', { diskPath, mountPath: mountDir })
await fromCallback(execFile, 'vhdimount', [diskPath, mountDir])
try {
let max = 0
let maxEntry
const entries = await readdir(mountDir)
entries.forEach(entry => {
const matches = RE_VHDI.exec(entry)
if (matches !== null) {
const value = +matches[1]
if (value > max) {
max = value
maxEntry = entry
}
}
})
if (max === 0) {
throw new Error('no disks found')
}
yield `${mountDir}/${maxEntry}`
} finally {
debug('umount VHD (fusermount)', { diskPath, mountPath: mountDir })
await fromCallback(execFile, 'fusermount', ['-uz', mountDir])
}
}
async *getDisk(diskId) {
if (this._useGetDiskLegacy) {
yield* this.#getDiskLegacy(diskId)
return
}
const handler = this._handler
// this is a disposable
const mountDir = yield getTmpDir()
// this is also a disposable
yield mount(handler, diskId, mountDir)
// this will yield disk path to caller
yield `${mountDir}/vhd0`
}
// partitionId values:
//
// - undefined: raw disk
// - `<partitionId>`: partitioned disk
// - `<pvId>/<vgName>/<lvName>`: LVM on a partitioned disk
// - `/<vgName>/lvName>`: LVM on a raw disk
async *getPartition(diskId, partitionId) {
const devicePath = yield this.getDisk(diskId)
if (partitionId === undefined) {
return yield this._getPartition(devicePath)
}
const isLvmPartition = partitionId.includes('/')
if (isLvmPartition) {
const [pvId, vgName, lvName] = partitionId.split('/')
const lvs = yield this._getLvmLogicalVolumes(devicePath, pvId !== '' ? pvId : undefined, vgName)
return yield this._getPartition(lvs.find(_ => _.lv_name === lvName).lv_path)
}
return yield this._getPartition(devicePath, await this._findPartition(devicePath, partitionId))
}
// if we use alias on this remote, we have to name the file alias.vhd
getVhdFileName(baseName) {
if (this.#useAlias()) {
@@ -257,6 +455,56 @@ export class RemoteAdapter {
return backups
}
listPartitionFiles(diskId, partitionId, path) {
return Disposable.use(this.getPartition(diskId, partitionId), async rootPath => {
path = resolveSubpath(rootPath, path)
const entriesMap = {}
await asyncEach(
await readdir(path),
async name => {
try {
const stats = await lstat(`${path}/${name}`)
if (stats.isDirectory()) {
entriesMap[name + '/'] = {}
} else if (stats.isFile()) {
entriesMap[name] = {}
}
} catch (error) {
if (error == null || error.code !== 'ENOENT') {
throw error
}
}
},
{ concurrency: 1 }
)
return entriesMap
})
}
listPartitions(diskId) {
return Disposable.use(this.getDisk(diskId), async devicePath => {
const partitions = await listPartitions(devicePath)
if (partitions.length === 0) {
try {
// handle potential raw LVM physical volume
return await this._listLvmLogicalVolumes(devicePath, undefined, partitions)
} catch (error) {
return []
}
}
const results = []
await asyncMapSettled(partitions, partition =>
partition.type === LVM_PARTITION_TYPE_MBR || partition.type === LVM_PARTITION_TYPE_GPT
? this._listLvmLogicalVolumes(devicePath, partition, results)
: results.push(partition)
)
return results
})
}
async listPoolMetadataBackups() {
const handler = this._handler
const safeReaddir = createSafeReaddir(handler, 'listPoolMetadataBackups')
@@ -315,11 +563,6 @@ export class RemoteAdapter {
fn(cache)
await this._writeCache(path, cache)
} else {
const regenerated = await this.#getCacheableDataListVmBackups(dirname(path))
if (regenerated !== undefined) {
await this._writeCache(path, regenerated)
}
}
}
@@ -388,7 +631,8 @@ export class RemoteAdapter {
return
}
await this._writeCache(path, backups)
// detached async action, will not reject
this._writeCache(path, backups)
return backups
}
@@ -453,7 +697,7 @@ export class RemoteAdapter {
return path
}
async writeVhd(path, disk, { validator = noop, writeBlockConcurrency, uuid, parentUuid, parentPath } = {}) {
async writeVhd(path, disk, { validator = noop, writeBlockConcurrency } = {}) {
const handler = this._handler
if (this.useVhdDirectory()) {
@@ -465,19 +709,11 @@ export class RemoteAdapter {
concurrency: writeBlockConcurrency,
validator,
compression: 'brotli',
uuid,
parentUuid,
parentPath,
},
})
} else {
const stream = await toVhdStream(disk, { uuid, parentUuid, parentPath })
const size = await this.outputStream(path, stream, {
validator,
// no checksum for VHDs, because they will be invalidated by
// merges and chains
checksum: false,
})
const stream = await toVhdStream(disk)
const size = await this.outputStream(path, stream, { validator, checksum: false })
await validator(path)
return size
}
@@ -655,8 +891,26 @@ Object.assign(RemoteAdapter.prototype, {
isValidXva,
})
// File-level-restore methods live in ./_fileRestore.mjs; mix them onto the prototype
// before decorating so decorateMethodsWith can wrap them.
Object.assign(RemoteAdapter.prototype, fileRestoreMethods)
decorateMethodsWith(RemoteAdapter, {
_getLvmLogicalVolumes: compose([
Disposable.factory,
[deduped, (devicePath, pvId, vgName) => [devicePath, pvId, vgName]],
debounceResourceFactory,
]),
decorateMethodsWith(RemoteAdapter, fileRestoreDecorators)
_getLvmPhysicalVolume: compose([
Disposable.factory,
[deduped, (devicePath, partition) => [devicePath, partition?.id]],
debounceResourceFactory,
]),
_getPartition: compose([
Disposable.factory,
[deduped, (devicePath, partition) => [devicePath, partition?.id]],
debounceResourceFactory,
]),
getDisk: compose([Disposable.factory, [deduped, diskId => [diskId]], debounceResourceFactory]),
getPartition: Disposable.factory,
})

View File

@@ -1,126 +0,0 @@
import { strict as assert } from 'node:assert'
import test from 'node:test'
import { execFile } from 'node:child_process'
import { copyFile, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { Stream } from 'node:stream'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { randomBytes } from 'node:crypto'
import Disposable from 'promise-toolbox/Disposable'
import { RemoteAdapter } from './RemoteAdapter.mjs'
// losetup/dmsetup/pvcreate/vgcreate/lvcreate/mkfs all require root (CAP_SYS_ADMIN).
const skip =
typeof process.getuid !== 'function' || process.getuid() !== 0 ? 'requires root (losetup/dmsetup/lvm)' : false
const pExec = promisify(execFile)
const uniqueName = () => `xotest${randomBytes(6).toString('hex')}`
async function withLoop(imagePath, fn) {
const loop = (await pExec('losetup', ['--show', '-f', imagePath])).stdout.trim()
try {
return await fn(loop)
} finally {
await pExec('losetup', ['-d', loop]).catch(() => {})
}
}
// Build a self-contained LVM image: a 64 MiB file holding one PV / VG / ext4 LV.
async function createLvmImage(imagePath, vgName, lvName) {
await pExec('truncate', ['-s', '64M', imagePath])
await withLoop(imagePath, async loop => {
await pExec('pvcreate', ['-f', '-y', loop])
await pExec('vgcreate', [vgName, loop])
await pExec('lvcreate', ['-y', '-l', '100%FREE', '-n', lvName, vgName])
await pExec('mkfs.ext4', ['-q', `/dev/${vgName}/${lvName}`])
await pExec('vgchange', ['-an', vgName])
})
}
// xo-proxy answers this stream through Koa, which pipes only bodies passing `instanceof Stream`
// and JSON-serializes anything else: node-tar >= 7 returns a duck-typed (Minipass) stream, which
// silently turned a proxied file restore into a JSON dump of the tar object. Every format must
// resolve with a real node stream.
test('fetchPartitionFiles resolves with a node:stream for every format', async t => {
const tmp = await mkdtemp(join(tmpdir(), 'xo-flr-fetch-'))
t.after(() => rm(tmp, { recursive: true, force: true }))
await writeFile(join(tmp, 'file.txt'), 'hello')
const adapter = new RemoteAdapter({})
// at this point of a real restore the partition is already mounted
adapter.getPartition = Disposable.factory(async function* () {
yield tmp
})
for (const [format, magic] of [
['tgz', Buffer.from([0x1f, 0x8b])],
['zip', Buffer.from('PK')],
]) {
const stream = await adapter.fetchPartitionFiles('disk', 'partition', ['/file.txt'], format)
assert.ok(stream instanceof Stream, `${format}: expected a node:stream instance`)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const archive = Buffer.concat(chunks)
assert.deepEqual(archive.subarray(0, magic.length), magic, `${format}: unexpected archive header`)
}
})
test('LVM file-restore against real losetup/dmsetup/lvm', { skip }, async t => {
const tmp = await mkdtemp(join(tmpdir(), 'xo-flr-'))
const adapter = new RemoteAdapter({})
const vg = uniqueName()
const lv = 'root_lv'
const image = join(tmp, 'disk.raw')
const clone = join(tmp, 'clone.raw')
const plain = join(tmp, 'plain.raw')
t.after(async () => {
await rm(tmp, { recursive: true, force: true })
// drop any stale online-cache entries pointing at the now-deleted images
await pExec('pvscan', ['--cache']).catch(() => {})
})
await createLvmImage(image, vg, lv)
await t.test('detects the PV and renames the VG to a unique name', async () => {
await Disposable.use(adapter._getLvmPhysicalVolume(image, undefined), async ({ originalVgName, vgName }) => {
assert.equal(originalVgName, vg)
assert.match(vgName, /^xo[0-9a-f]{16}$/)
assert.notEqual(vgName, vg)
})
})
await t.test('two clones of the same VM (shared PVID) list concurrently without collision', async () => {
await copyFile(image, clone) // exact clone → identical PVID + VG name + VG UUID
const [a, b] = await Promise.all([
adapter._listLvmLogicalVolumes(image, undefined, []),
adapter._listLvmLogicalVolumes(clone, undefined, []),
])
assert.equal(a.length, 1)
assert.equal(b.length, 1)
// the human-readable name is the (shared) original; the id carries the unique renamed VG
assert.equal(a[0].name, `${vg}/${lv}`)
assert.equal(b[0].name, `${vg}/${lv}`)
assert.notEqual(a[0].id, b[0].id)
})
await t.test('a non-LVM partition is rejected without building a snapshot', async () => {
await pExec('truncate', ['-s', '16M', plain])
await pExec('mkfs.ext4', ['-q', plain])
await assert.rejects(
Disposable.use(adapter._getLvmPhysicalVolume(plain, undefined), async () => {}),
/no LVM physical volume/
)
})
await t.test('leaves no dangling xo-pv-* dm-snapshots behind', async () => {
const { stdout } = await pExec('dmsetup', ['ls']).catch(() => ({ stdout: '' }))
assert.equal(/xo-pv-/.test(stdout), false, `leftover snapshots:\n${stdout}`)
})
})

View File

@@ -1,485 +0,0 @@
// File-level-restore (FLR) methods for RemoteAdapter.
//
// These were extracted from RemoteAdapter.mjs to keep that file focused on backup
// CRUD/cache/metadata. They are mixed back onto `RemoteAdapter.prototype` via
// `Object.assign` and decorated via `decorateMethodsWith` in RemoteAdapter.mjs, so `this`
// is the RemoteAdapter instance at call time (`this._handler`, `this._debounceResource`,
// `this._useGetDiskLegacy` keep working unchanged). The logger keeps the original
// `xo:backups:RemoteAdapter` namespace so existing debug filters are unaffected.
import { asyncEach } from '@vates/async-each'
import { asyncMapSettled } from '@xen-orchestra/async-map'
import { compose } from '@vates/compose'
import { createLogger } from '@xen-orchestra/log'
import { deduped } from '@vates/disposable/deduped.js'
import { randomBytes } from 'node:crypto'
import { Readable } from 'node:stream'
import { join, resolve } from 'node:path'
import { execFile } from 'child_process'
import { finished } from 'node:stream/promises'
import { lstat, open, readdir, unlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { mount } from '@vates/fuse-vhd'
import { ZipFile } from 'yazl'
import Disposable from 'promise-toolbox/Disposable'
import fromCallback from 'promise-toolbox/fromCallback'
import pDefer from 'promise-toolbox/defer'
import * as tar from 'tar'
import { getTmpDir } from './_getTmpDir.mjs'
import {
listPartitions,
LINUX_DATA_PARTITION_TYPE_GPT,
LINUX_DATA_PARTITION_TYPE_MBR,
LVM_PARTITION_TYPE_GPT,
LVM_PARTITION_TYPE_MBR,
} from './_listPartitions.mjs'
import { lvs, pvs } from './_lvm.mjs'
const { debug, warn } = createLogger('xo:backups:RemoteAdapter')
const noop = Function.prototype
// Restrict LVM scanning to a single device. Backup PVs are clones: the very same PVID
// appears at once on the raw loop device, the dm-snapshot overlaid on it, and every
// other restored copy of the same VM. An unscoped pvs/pvscan/vgimportclone/vgchange then
// aborts with "duplicate PV ... for PVID ..." and the VG can neither be renamed nor
// activated. Accepting only the device in hand removes every duplicate from LVM's view.
export const lvmOnlyDevice = devicePath => `devices { global_filter=[ "a|^${devicePath}$|", "r|.*|" ] }`
// Partition-type predicates (exported for unit tests).
export const isLvmPartitionType = type => type === LVM_PARTITION_TYPE_MBR || type === LVM_PARTITION_TYPE_GPT
// Some installers (e.g. Ubuntu subiquity) place an LVM PV on a generic Linux-data partition
// instead of the LVM type, so those are the only non-LVM types worth the (expensive) probe.
export const isProbeableForLvm = type =>
type === LINUX_DATA_PARTITION_TYPE_MBR || type === LINUX_DATA_PARTITION_TYPE_GPT
// Build the partition entries for a PV's logical volumes (exported for unit tests).
// Shows "ubuntu-vg/ubuntu-lv" for readability; skips unnamed LVs.
export const toLvPartitions = (partitionId, originalVgName, lvItems) =>
lvItems
.filter(lv => lv.lv_name !== '')
.map(lv => ({
id: `${partitionId}/${lv.vg_name}/${lv.lv_name}`,
name: originalVgName !== undefined ? `${originalVgName}/${lv.lv_name}` : lv.lv_name,
size: lv.lv_size,
}))
const makeRelative = path => resolve('/', path).slice(1)
const resolveSubpath = (root, path) => resolve(root, makeRelative(path))
async function addZipEntries(zip, realBasePath, virtualBasePath, relativePaths) {
for (const relativePath of relativePaths) {
const realPath = join(realBasePath, relativePath)
const virtualPath = join(virtualBasePath, relativePath)
const stats = await lstat(realPath)
const { mode, mtime } = stats
const opts = { mode, mtime }
if (stats.isDirectory()) {
zip.addEmptyDirectory(virtualPath, opts)
await addZipEntries(zip, realPath, virtualPath, await readdir(realPath))
} else if (stats.isFile()) {
zip.addFile(realPath, virtualPath, opts)
}
}
}
const debounceResourceFactory = factory =>
function () {
return this._debounceResource(factory.apply(this, arguments))
}
// legacy disk mount via vhdimount; extracted from a private method so it can live in this
// module (mixin methods cannot reference class-private members).
async function* getDiskLegacy(handler, diskId) {
const RE_VHDI = /^vhdi(\d+)$/
const diskPath = handler.getFilePath('/' + diskId)
const mountDir = yield getTmpDir()
debug('mount VHD (vhdimount)', { diskPath, mountPath: mountDir })
await fromCallback(execFile, 'vhdimount', [diskPath, mountDir])
try {
let max = 0
let maxEntry
const entries = await readdir(mountDir)
entries.forEach(entry => {
const matches = RE_VHDI.exec(entry)
if (matches !== null) {
const value = +matches[1]
if (value > max) {
max = value
maxEntry = entry
}
}
})
if (max === 0) {
throw new Error('no disks found')
}
yield `${mountDir}/${maxEntry}`
} finally {
debug('umount VHD (fusermount)', { diskPath, mountPath: mountDir })
await fromCallback(execFile, 'fusermount', ['-uz', mountDir])
}
}
// Mixed onto RemoteAdapter.prototype — `this` is the RemoteAdapter instance.
export const fileRestoreMethods = {
async _findPartition(devicePath, partitionId) {
const partitions = await listPartitions(devicePath)
const partition = partitions.find(_ => _.id === partitionId)
if (partition === undefined) {
throw new Error(`partition ${partitionId} not found`)
}
return partition
},
async *_getLvmLogicalVolumes(devicePath, pvId, requestedVgName) {
const { vgName, lvmConfig } = yield this._getLvmPhysicalVolume(
devicePath,
pvId && (await this._findPartition(devicePath, pvId))
)
// vgName is the unique name vgimportclone just assigned to this PV; prefer it over the
// (possibly stale) name embedded in the partitionId. lvmConfig scopes every command to
// this device so a colliding/duplicate VG on the host or another copy can't shadow it.
const effectiveVgName = vgName ?? requestedVgName
debug('activate LVM volume group', { effectiveVgName, requestedVgName })
await fromCallback(execFile, 'vgchange', ['--config', lvmConfig, '-ay', effectiveVgName])
try {
debug('get LVM logical volumes', { effectiveVgName })
yield lvs(['lv_name', 'lv_path'], '--config', lvmConfig, effectiveVgName)
} finally {
debug('deactivate LVM volume group', { effectiveVgName })
await fromCallback(execFile, 'vgchange', ['--config', lvmConfig, '-an', effectiveVgName])
}
},
async *_getLvmPhysicalVolume(devicePath, partition) {
const loopArgs = []
if (partition !== undefined) {
loopArgs.push('-o', partition.start * 512, '--sizelimit', partition.size)
}
loopArgs.push('--show', '-f', devicePath)
debug('attach loop device', { devicePath, partition })
const loopDevice = (await fromCallback(execFile, 'losetup', loopArgs)).trim()
let cowPath, cowLoop, mapperName, vgName, lvmConfig
try {
// Cheap pre-check, scoped to this loop so a duplicate PVID (another copy of the same
// VM restored concurrently) can't make it fail: is there an LVM PV here at all?
// Non-PV partitions (/boot, EFI, raw disks) skip the whole dm-snapshot machinery.
const [originalVgName] = (
await pvs('vg_name', '--config', lvmOnlyDevice(loopDevice), loopDevice).catch(() => [])
).filter(Boolean)
if (originalVgName === undefined) {
const where = partition !== undefined ? ` partition ${partition.id}` : ''
throw new Error(`no LVM physical volume on ${devicePath}${where}`)
}
// The backup is read-only, so overlay a writable dm-snapshot to let vgimportclone
// rewrite metadata, and rename the VG to a unique name so concurrent clones (identical
// PVID and VG name) don't collide on device-mapper node names. Every LVM command is
// scoped to the snapshot device (lvmConfig) to keep duplicate PVIDs out of LVM's view.
mapperName = `xo-pv-${randomBytes(4).toString('hex')}`
const mapperPath = `/dev/mapper/${mapperName}`
lvmConfig = lvmOnlyDevice(mapperPath)
// ~4 MB sparse COW is enough to hold the LVM metadata rewrites
cowPath = join(tmpdir(), `${mapperName}.cow`)
const fh = await open(cowPath, 'w')
await fh.truncate(4 * 1024 * 1024)
await fh.close()
cowLoop = (await fromCallback(execFile, 'losetup', ['--show', '-f', cowPath])).trim()
const sectors = (await fromCallback(execFile, 'blockdev', ['--getsz', loopDevice])).trim()
await fromCallback(execFile, 'dmsetup', [
'create',
mapperName,
'--table',
`0 ${sectors} snapshot ${loopDevice} ${cowLoop} P 8`,
])
// Unique random VG name: list and mount each query/yield the name from the device, so
// it need not be deterministic — only collision-free across concurrent clones.
vgName = `xo${randomBytes(8).toString('hex')}`
debug('import LVM volume group with unique name via dm-snapshot', { vgName, originalVgName })
await fromCallback(execFile, 'vgimportclone', ['--config', lvmConfig, '--basevgname', vgName, mapperPath])
yield { path: mapperPath, originalVgName, vgName, lvmConfig }
} finally {
if (mapperName !== undefined) {
// best-effort deactivate (a no-op if it was never activated) before removing the snapshot
await fromCallback(execFile, 'vgchange', ['--config', lvmConfig, '-an', vgName]).catch(noop)
debug('remove dm-snapshot', { mapperName })
await fromCallback(execFile, 'dmsetup', ['remove', mapperName]).catch(err =>
warn('failed to remove dm-snapshot', { mapperName, error: err })
)
}
if (cowLoop !== undefined) {
await fromCallback(execFile, 'losetup', ['-d', cowLoop]).catch(noop)
}
if (cowPath !== undefined) {
await unlink(cowPath).catch(noop)
}
debug('detach loop device', { loopDevice })
await fromCallback(execFile, 'losetup', ['-d', loopDevice])
}
},
async *_getPartition(devicePath, partition) {
const options = ['loop', 'ro']
if (partition !== undefined) {
const { size, start } = partition
options.push(`sizelimit=${size}`)
if (start !== undefined) {
options.push(`offset=${start * 512}`)
}
}
const path = yield getTmpDir()
const mount = options => {
debug('mount device', { devicePath, mountPath: path })
return fromCallback(execFile, 'mount', [
`--options=${options.join(',')}`,
`--source=${devicePath}`,
`--target=${path}`,
]).catch(error => {
if (error.stderr) {
error.message = `${error.message}: ${error.stderr.trim()}`
}
throw error
})
}
// norecovery prevents mount from attempting journal replay on a read-only device (ext3/ext4/xfs).
// Other filesystems don't support it, so fall back without it on failure.
try {
await mount([...options, 'norecovery'])
} catch (error) {
await mount(options)
}
try {
yield path
} finally {
debug('umount device', { devicePath, mountPath: path })
await fromCallback(execFile, 'umount', ['--lazy', path])
}
},
_listLvmLogicalVolumes(devicePath, partition, results = []) {
return Disposable.use(
this._getLvmPhysicalVolume(devicePath, partition),
async ({ path, originalVgName, lvmConfig }) => {
const lvItems = await pvs(['lv_name', 'lv_path', 'lv_size', 'vg_name'], '--config', lvmConfig, path)
const partitionId = partition !== undefined ? partition.id : ''
results.push(...toLvPartitions(partitionId, originalVgName, lvItems))
return results
}
)
},
fetchPartitionFiles(diskId, partitionId, paths, format) {
const { promise, reject, resolve } = pDefer()
const self = this
Disposable.use(async function* () {
const path = yield self.getPartition(diskId, partitionId)
let outputStream
if (format === 'tgz') {
// process one entry at a time with { job: 1}. node-tar defaults to 4
// concurrent jobs, which on a FUSE-backed restore mount means up to 4
// simultaneous reads. Those saturate the libuv threadpool and starve
// the underlying vhd/CIFS reads NTFS-3g depends on (FUSE-on-FUSE
// threadpool deadlock). Serializing keeps a worker free for them.
const pack = tar.c({ cwd: path, gzip: true, jobs: 1 }, paths.map(makeRelative))
// node-tar >= 7 returns a Minipass stream: it has `pipe()` but is not a `node:stream`
// instance. xo-proxy serves this stream through Koa, which pipes only bodies passing
// `instanceof Stream` and JSON-serializes anything else — so a file restore through a
// proxy answered a JSON dump of the tar object instead of the archive. Wrapping it in a
// real node stream also propagates tar errors to the consumer and aborts the tar job
// when the client disconnects.
outputStream = Readable.from(pack, { objectMode: false })
resolve(outputStream)
} else if (format === 'zip') {
const zip = new ZipFile()
// Resolve with the stream before enumeration so the client can start
// receiving data immediately — addZipEntries over FUSE/S3 can take
// minutes for large trees (e.g. node_modules) and would otherwise
// appear as a freeze with no response sent.
outputStream = zip.outputStream
resolve(zip.outputStream)
await addZipEntries(zip, path, '', paths.map(makeRelative))
zip.end()
} else {
throw new Error('unsupported format ' + format)
}
await finished(outputStream).catch(noop)
}).catch(error => {
warn(error)
reject(error)
})
return promise
},
async *getDisk(diskId) {
if (this._useGetDiskLegacy) {
yield* getDiskLegacy(this._handler, diskId)
return
}
const handler = this._handler
// this is a disposable
const mountDir = yield getTmpDir()
// this is also a disposable
yield mount(handler, diskId, mountDir)
// this will yield disk path to caller
yield `${mountDir}/vhd0`
},
// partitionId values:
//
// - undefined: raw disk
// - `<partitionId>`: partitioned disk
// - `<pvId>/<vgName>/<lvName>`: LVM on a partitioned disk
// - `/<vgName>/lvName>`: LVM on a raw disk
async *getPartition(diskId, partitionId) {
const devicePath = yield this.getDisk(diskId)
if (partitionId === undefined) {
debug(
'no partition specified, attempting raw disk mount — call listPartitions first if disk has partitions or LVM'
)
return yield this._getPartition(devicePath)
}
const isLvmPartition = partitionId.includes('/')
if (isLvmPartition) {
const [pvId, vgName, lvName] = partitionId.split('/')
const lvs = yield this._getLvmLogicalVolumes(devicePath, pvId !== '' ? pvId : undefined, vgName)
return yield this._getPartition(lvs.find(_ => _.lv_name === lvName).lv_path)
}
return yield this._getPartition(devicePath, await this._findPartition(devicePath, partitionId))
},
listPartitionFiles(diskId, partitionId, path) {
return Disposable.use(this.getPartition(diskId, partitionId), async rootPath => {
path = resolveSubpath(rootPath, path)
const entriesMap = {}
await asyncEach(
await readdir(path),
async name => {
try {
const stats = await lstat(`${path}/${name}`)
if (stats.isDirectory()) {
entriesMap[name + '/'] = {}
} else if (stats.isFile()) {
entriesMap[name] = {}
}
} catch (error) {
if (error == null || error.code !== 'ENOENT') {
throw error
}
}
},
{ concurrency: 1 }
)
return entriesMap
})
},
listPartitions(diskId) {
return Disposable.use(this.getDisk(diskId), async devicePath => {
// partx may return empty on FUSE-backed files (vhd0); a loop device
// presents proper block-device semantics that partx reads reliably.
// losetup may itself fail if the FUSE mount isn't fully ready yet —
// fall back to the direct path so listPartitions returns [] instead of throwing.
let loopForParts, partitions
try {
loopForParts = (await fromCallback(execFile, 'losetup', ['--show', '-f', devicePath])).trim()
partitions = await listPartitions(loopForParts)
} catch (error) {
debug('partition probe via loop device failed, falling back to direct path', { error })
partitions = await listPartitions(devicePath)
} finally {
if (loopForParts !== undefined) {
await fromCallback(execFile, 'losetup', ['-d', loopForParts]).catch(noop)
}
}
if (partitions.length === 0) {
try {
// handle potential raw LVM physical volume
return await this._listLvmLogicalVolumes(devicePath, undefined, partitions)
} catch (error) {
return []
}
}
const results = []
await asyncMapSettled(partitions, async partition => {
if (isLvmPartitionType(partition.type)) {
return this._listLvmLogicalVolumes(devicePath, partition, results)
}
// Only generic Linux-data partitions can hide an LVM PV (subiquity-style); other
// types — BIOS boot, EFI, swap, … — are never PVs, so list them directly without
// the (expensive) loop + dm-snapshot + vgimportclone probe.
if (!isProbeableForLvm(partition.type)) {
results.push(partition)
return
}
const lvResults = []
try {
await this._listLvmLogicalVolumes(devicePath, partition, lvResults)
} catch (error) {
debug('LVM probe failed for Linux-data partition, treating as regular partition', {
partition,
error,
})
}
if (lvResults.length > 0) {
results.push(...lvResults)
} else {
results.push(partition)
}
})
return results
})
},
}
// Applied by RemoteAdapter.mjs via decorateMethodsWith(RemoteAdapter, fileRestoreDecorators).
// debounceResourceFactory/deduped reference `this._debounceResource` at call time.
export const fileRestoreDecorators = {
_getLvmLogicalVolumes: compose([
Disposable.factory,
[deduped, (devicePath, pvId, vgName) => [devicePath, pvId, vgName]],
debounceResourceFactory,
]),
_getLvmPhysicalVolume: compose([
Disposable.factory,
[deduped, (devicePath, partition) => [devicePath, partition?.id]],
debounceResourceFactory,
]),
_getPartition: compose([
Disposable.factory,
[deduped, (devicePath, partition) => [devicePath, partition?.id]],
debounceResourceFactory,
]),
getDisk: compose([Disposable.factory, [deduped, diskId => [diskId]], debounceResourceFactory]),
getPartition: Disposable.factory,
}

View File

@@ -1,61 +0,0 @@
import { strict as assert } from 'node:assert'
import test from 'node:test'
import { isLvmPartitionType, isProbeableForLvm, lvmOnlyDevice, toLvPartitions } from './_fileRestore.mjs'
import {
LINUX_DATA_PARTITION_TYPE_GPT,
LINUX_DATA_PARTITION_TYPE_MBR,
LVM_PARTITION_TYPE_GPT,
LVM_PARTITION_TYPE_MBR,
} from './_listPartitions.mjs'
// well-known GPT type GUIDs that must NOT be probed/treated as LVM
const EFI = 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b'
const BIOS_BOOT = '21686148-6449-6e6f-744e-656564454649'
const SWAP = '0657fd6d-a4ab-43c4-84e5-0933c84b4f4f'
test('lvmOnlyDevice builds an accept-only global_filter for the device', () => {
assert.equal(
lvmOnlyDevice('/dev/mapper/xo-pv-abcd1234'),
'devices { global_filter=[ "a|^/dev/mapper/xo-pv-abcd1234$|", "r|.*|" ] }'
)
assert.equal(lvmOnlyDevice('/dev/loop7'), 'devices { global_filter=[ "a|^/dev/loop7$|", "r|.*|" ] }')
})
test('isLvmPartitionType matches only the LVM partition types', () => {
assert.equal(isLvmPartitionType(LVM_PARTITION_TYPE_MBR), true)
assert.equal(isLvmPartitionType(LVM_PARTITION_TYPE_GPT), true)
assert.equal(isLvmPartitionType(LINUX_DATA_PARTITION_TYPE_GPT), false)
assert.equal(isLvmPartitionType(EFI), false)
assert.equal(isLvmPartitionType(undefined), false)
})
test('isProbeableForLvm matches only generic Linux-data types', () => {
assert.equal(isProbeableForLvm(LINUX_DATA_PARTITION_TYPE_MBR), true)
assert.equal(isProbeableForLvm(LINUX_DATA_PARTITION_TYPE_GPT), true)
// never probe these
assert.equal(isProbeableForLvm(LVM_PARTITION_TYPE_GPT), false)
assert.equal(isProbeableForLvm(BIOS_BOOT), false)
assert.equal(isProbeableForLvm(EFI), false)
assert.equal(isProbeableForLvm(SWAP), false)
assert.equal(isProbeableForLvm(undefined), false)
})
test('toLvPartitions builds entries, skips unnamed LVs, shows the original VG name', () => {
const lvItems = [
{ lv_name: 'ubuntu-lv', lv_path: '/dev/xohash/ubuntu-lv', lv_size: '1000', vg_name: 'xohash' },
// a PV with no LV yields a row with an empty lv_name → must be skipped
{ lv_name: '', lv_path: '', lv_size: '0', vg_name: 'xohash' },
{ lv_name: 'swap_1', lv_path: '/dev/xohash/swap_1', lv_size: '500', vg_name: 'xohash' },
]
assert.deepEqual(toLvPartitions('part3', 'ubuntu-vg', lvItems), [
{ id: 'part3/xohash/ubuntu-lv', name: 'ubuntu-vg/ubuntu-lv', size: '1000' },
{ id: 'part3/xohash/swap_1', name: 'ubuntu-vg/swap_1', size: '500' },
])
})
test('toLvPartitions falls back to the bare LV name when the original VG name is unknown', () => {
const lvItems = [{ lv_name: 'data', lv_path: '/dev/xohash/data', lv_size: '42', vg_name: 'xohash' }]
// raw-disk PV → empty partitionId
assert.deepEqual(toLvPartitions('', undefined, lvItems), [{ id: '/xohash/data', name: 'data', size: '42' }])
})

View File

@@ -16,44 +16,6 @@ import { toQcow2Stream } from '@xen-orchestra/qcow2'
const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value])
const orderedMemoryLimits = ['memory_static_min', 'memory_dynamic_min', 'memory_dynamic_max', 'memory_static_max']
// The dynamic memory range MUST respect this inequality at any moment: static_min <= dynamic_min <= dynamic_max <= static_max.
// We must update these properties in the right order to avoid XAPI error.
// The order depends on the values. It can be an increase, a decrease or a mix of both, so any order could be required.
export async function updateMemoryFields(xapi, targetVm, vmRecord) {
const memoryValues = {}
for (const key of orderedMemoryLimits) {
memoryValues[key] = {
currentValue: targetVm[key],
newValue: vmRecord[key] ?? targetVm[key],
}
}
while (await updateNextMemoryField(xapi, memoryValues, targetVm.$ref)) {
/* execute until all memory fields are updated */
}
}
// Update one more memory field if needed, then return a boolean describing if a field was updated or if all fields are up to date
async function updateNextMemoryField(xapi, memoryValues, vmRef) {
for (let i = 0; i < orderedMemoryLimits.length; i++) {
const currentField = memoryValues[orderedMemoryLimits[i]]
const nextField = i === orderedMemoryLimits.length - 1 ? undefined : memoryValues[orderedMemoryLimits[i + 1]]
if (
currentField.newValue !== currentField.currentValue &&
(nextField === undefined || currentField.newValue <= nextField.currentValue)
) {
// no need to check that previousField.currentValue <= currentField.newValue, as we can deduce it
await xapi.setField('VM', vmRef, orderedMemoryLimits[i], currentField.newValue)
await xapi.barrier()
currentField.currentValue = currentField.newValue
return true
}
}
return false
}
export async function exportIncrementalVm(
vm,
baseVdis = {},
@@ -198,9 +160,6 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
let vmRef
if (isUpdate) {
vmRef = targetRef
await updateMemoryFields(xapi, targetVm, vmRecord)
await Promise.all([
xapi.setFields('VM', vmRef, {
actions_after_crash: vmRecord.actions_after_crash,
@@ -212,6 +171,10 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
HVM_boot_params: vmRecord.HVM_boot_params,
HVM_boot_policy: vmRecord.HVM_boot_policy,
HVM_shadow_multiplier: vmRecord.HVM_shadow_multiplier,
memory_dynamic_max: vmRecord.memory_dynamic_max,
memory_dynamic_min: vmRecord.memory_dynamic_min,
memory_static_max: vmRecord.memory_static_max,
memory_static_min: vmRecord.memory_static_min,
name_label: vmRecord.name_label,
name_description: vmRecord.name_description,
order: vmRecord.order,

View File

@@ -1,122 +0,0 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { updateMemoryFields } from './_incrementalVm.mjs'
const MOCK_REF = 'mockRef'
function mockXapiVm(initialMemoryValues) {
const res = {
...initialMemoryValues,
updates: [],
barrier: () => {},
setField: (type, ref, field, value) => {
assert(type === 'VM')
assert(ref === MOCK_REF)
res.updates.push({ field, value })
res[field] = value
assert(res.memory_static_min <= res.memory_dynamic_min)
assert(res.memory_dynamic_min <= res.memory_dynamic_max)
assert(res.memory_dynamic_max <= res.memory_static_max)
},
}
return res
}
const tests = [
{
label: 'handles a memory increase',
targetVm: {
memory_static_min: 2,
memory_dynamic_min: 4,
memory_dynamic_max: 4,
memory_static_max: 8,
},
vmRecord: {
memory_static_min: 16,
memory_dynamic_min: 16,
memory_dynamic_max: 16,
memory_static_max: 16,
},
expectedUpdates: [
{ field: 'memory_static_max', value: 16 },
{ field: 'memory_dynamic_max', value: 16 },
{ field: 'memory_dynamic_min', value: 16 },
{ field: 'memory_static_min', value: 16 },
],
},
{
label: 'handles a memory decrease',
targetVm: {
memory_static_min: 4,
memory_dynamic_min: 8,
memory_dynamic_max: 16,
memory_static_max: 16,
},
vmRecord: {
memory_static_min: 2,
memory_dynamic_min: 2,
memory_dynamic_max: 2,
memory_static_max: 2,
},
expectedUpdates: [
{ field: 'memory_static_min', value: 2 },
{ field: 'memory_dynamic_min', value: 2 },
{ field: 'memory_dynamic_max', value: 2 },
{ field: 'memory_static_max', value: 2 },
],
},
{
label: 'handles a more complex case',
targetVm: {
memory_static_min: 1,
memory_dynamic_min: 2,
memory_dynamic_max: 8,
memory_static_max: 16,
},
vmRecord: {
memory_static_min: 4,
memory_dynamic_min: 4,
memory_dynamic_max: 4,
memory_static_max: 4,
},
expectedUpdates: [
{ field: 'memory_dynamic_min', value: 4 },
{ field: 'memory_static_min', value: 4 },
{ field: 'memory_dynamic_max', value: 4 },
{ field: 'memory_static_max', value: 4 },
],
},
{
label: 'avoids updating identical value',
targetVm: {
memory_static_min: 1,
memory_dynamic_min: 2,
memory_dynamic_max: 4,
memory_static_max: 8,
},
vmRecord: {
memory_static_min: 1,
memory_dynamic_min: 2,
memory_dynamic_max: 4,
memory_static_max: 8,
},
expectedUpdates: [],
},
]
describe('updateMemoryFields() should update in the right order', () => {
for (const test of tests) {
it(test.label, async () => {
const mock = mockXapiVm(test.targetVm)
test.targetVm.$ref = MOCK_REF
await updateMemoryFields(mock, test.targetVm, test.vmRecord)
assert.equal(mock.updates.length, test.expectedUpdates.length)
for (let i = 0; i < mock.updates.length; i++) {
assert.equal(mock.updates[i].value, test.expectedUpdates[i].value)
assert.equal(mock.updates[i].field, test.expectedUpdates[i].field)
}
})
}
})

View File

@@ -29,15 +29,6 @@ export const LVM_PARTITION_TYPE_MBR = 0x8e
// GPT LVM type
export const LVM_PARTITION_TYPE_GPT = 'e6d6d379-f507-44c2-a23c-238f2a3df928'
// Generic "Linux filesystem data" types. These are the only non-LVM-typed
// partitions worth probing for a hidden LVM PV, because some installers (e.g.
// Ubuntu subiquity) place a PV on a partition left with this generic type.
// Other types (BIOS boot, EFI, swap, …) are never LVM PVs.
// MBR Linux native
export const LINUX_DATA_PARTITION_TYPE_MBR = 0x83
// GPT Linux filesystem data
export const LINUX_DATA_PARTITION_TYPE_GPT = '0fc63daf-8483-4772-8e79-3d69d8477de4'
const parsePartxLine = createParser({
keyTransform: key => (key === 'UUID' ? 'id' : key.toLowerCase()),
valueTransform: (value, key) => {

View File

@@ -33,13 +33,6 @@ export const VM_UUID = 'xo:backup:vm'
// allows direction-agnostic discovery of common base VDIs (e.g. for failback/reverse replication)
export const CONTENT_KEY = 'xo:backup:contentKey'
// in `other_config` of a replicated VM and its VDIs
// set to the string 'true' on every write done by code that includes the non-NBD qcow2
// corruption fix (fix(disk-transform): erroneous DiskLargerBlock copy offset). Its absence
// on a qcow2 full means the replica may be silently corrupt and its chain must be re-based
// with a clean full. Named after the specific fix so future fixes get their own flag.
export const INCLUDE_NON_NBD_QCOW2_FIX = 'xo:backup:includeNonNbdQcow2Fix'
async function listVdiRefs(xapi, vmRef) {
return xapi.VM_getDisks(vmRef)
}
@@ -97,7 +90,6 @@ export function resetVmOtherConfig(xapi, vmRef) {
[DATETIME]: null,
[DELTA_CHAIN_LENGTH]: null,
[EXPORTED_SUCCESSFULLY]: null,
[INCLUDE_NON_NBD_QCOW2_FIX]: null,
[JOB_ID]: null,
[SCHEDULE_ID]: null,
[VM_UUID]: null,
@@ -128,10 +120,6 @@ export async function setVmOtherConfig(xapi, vmRef, { timestamp, jobId, schedule
xapi.setFieldEntries(type, ref, 'other_config', {
[REPLICATED_TO_SR_UUID]: srUuid,
[DATETIME]: formatDateTime(timestamp),
// stamped on every write: its presence marks the object as produced by code
// that includes the non-NBD qcow2 corruption fix, so a chain rooted in a pre-fix
// qcow2 full (missing this flag) can be detected and re-based with a clean full
[INCLUDE_NON_NBD_QCOW2_FIX]: 'true',
[JOB_ID]: jobId,
[SCHEDULE_ID]: scheduleId,
[VM_UUID]: vmUuid,

View File

@@ -1,4 +1,4 @@
import { asyncEach } from '@vates/async-each'
import { asyncMap } from '@xen-orchestra/async-map'
import { Task } from '@vates/task'
import Disposable from 'promise-toolbox/Disposable'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
@@ -82,11 +82,10 @@ export const Metadata = class MetadataBackupRunner extends Abstract {
// remove pools that failed (already handled)
pools = pools.filter(_ => _ !== undefined)
const tasks = []
if (settings.retentionPoolMetadata !== 0) {
for (const pool of pools) {
tasks.push(async () =>
const promises = []
if (pools.length !== 0 && settings.retentionPoolMetadata !== 0) {
promises.push(
asyncMap(pools, async pool =>
Task.run(
{
properties: {
@@ -108,11 +107,11 @@ export const Metadata = class MetadataBackupRunner extends Abstract {
}).run()
).catch(noop)
)
}
)
}
if (job.xoMetadata !== undefined && settings.retentionXoMetadata !== 0) {
tasks.push(() =>
promises.push(
Task.run(
{
properties: {
@@ -131,16 +130,7 @@ export const Metadata = class MetadataBackupRunner extends Abstract {
).catch(noop)
)
}
const total = tasks.length
let transferred = 0
await asyncEach(tasks, async task => {
await task()
transferred++
Task.set('progress', Math.round(transferred / total))
})
Task.set('progress', 1)
await Promise.all(promises)
}
)
}

View File

@@ -63,8 +63,6 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
const vmsUuids = await sourceRemoteAdapter.listAllVms()
Task.info('vms', { vms: vmsUuids })
const nbVms = vmsUuids.length
let nbVmsDone = 0
remoteAdapters = getAdaptersByRemote(remoteAdapters)
const allSettings = this._job.settings
@@ -73,6 +71,7 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
const queue = new Set(vmsUuids)
const taskByVmId = {}
const nTriesByVmId = {}
const vmErrors = []
const handleVm = vmUuid => {
if (nTriesByVmId[vmUuid] === undefined) {
@@ -125,19 +124,16 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
)
.then(result => {
if (taskError === undefined) {
nbVmsDone++
return task.success(result)
}
if (isLastRun) {
nbVmsDone++
vmErrors.push(taskError)
return task.failure(taskError)
}
Task.set('progress', Math.round((nbVmsDone * 100) / nbVms))
// don't end the task
task.warning(`Retry the VM mirror backup due to an error`, {
attempt: nTriesByVmId[vmUuid],
error: taskError.message,
isRetry: true,
})
queue.add(vmUuid)
})
@@ -154,7 +150,8 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
await asyncMapSettled(vmIds, _handleVm)
}
Task.set('progress', 100)
this._throwVmErrors(vmErrors)
}
)
}

View File

@@ -98,17 +98,14 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
const queue = new Set(vmIds)
const taskByVmId = {}
const nTriesByVmId = {}
const vmErrors = []
const handleVm = vmUuid => {
const getVmTask = () => {
const started = taskByVmId[vmUuid] !== undefined
if (!started) {
if (taskByVmId[vmUuid] === undefined) {
taskByVmId[vmUuid] = new Task(taskStart)
}
return {
task: taskByVmId[vmUuid],
started,
}
return taskByVmId[vmUuid]
}
const vmBackupFailed = async (error, task) => {
if (isLastRun) {
@@ -118,7 +115,6 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
task.warning(`Retry the VM backup due to an error`, {
attempt: nTriesByVmId[vmUuid],
error: error.message,
isRetry: true,
})
queue.add(vmUuid)
}
@@ -140,7 +136,7 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
taskStart.properties.name_label = vm.name_label
}
const { task } = getVmTask()
const task = getVmTask()
// error has to be caught in the task to prevent its failure, but handled outside the task to execute another task.run()
let taskError
return task
@@ -179,18 +175,19 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
task.success(result)
} else {
// ending the task with error or not ending the task
return vmBackupFailed(taskError, task)
vmBackupFailed(taskError, task)
if (isLastRun) {
vmErrors.push(taskError)
}
}
})
.catch(noop) // errors are handled by logs
}),
error => {
const { task: vmTask, started } = getVmTask()
if (!started) {
// the task is not started (except if it's a retry), and an unstarted task can't be failed
vmTask.start()
vmBackupFailed(error, getVmTask())
if (isLastRun) {
vmErrors.push(error)
}
return vmBackupFailed(error, vmTask)
}
)
}
@@ -203,6 +200,8 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
await asyncMapSettled(vmIds, _handleVm)
}
this._throwVmErrors(vmErrors)
}
)
}

View File

@@ -11,7 +11,22 @@ export const DEFAULT_SETTINGS = {
reportWhen: 'failure',
}
export class AggregateError extends Error {
constructor(errors, message) {
super(message)
this.errors = errors
}
}
export const Abstract = class AbstractRunner {
_throwVmErrors(errors) {
if (errors.length === 1) {
throw errors[0]
} else if (errors.length > 1) {
throw new AggregateError(errors, `${errors.length} VMs failed`)
}
}
constructor({ config, getAdapter, getConnectedRecord, job, schedule }) {
this._config = config
this._getRecord = getConnectedRecord

View File

@@ -1,13 +1,10 @@
import { asyncMap } from '@xen-orchestra/async-map'
import { createLogger } from '@xen-orchestra/log'
import { Task } from '@vates/task'
import { DIR_XO_POOL_METADATA_BACKUPS } from '../RemoteAdapter.mjs'
import { forkStreamUnpipe } from './_forkStreamUnpipe.mjs'
import { formatFilenameDate } from '../_filenameDate.mjs'
const { debug } = createLogger('xo:backups:PoolMetadataBackup')
export const PATH_DB_DUMP = '/pool/xmldbdump'
export class PoolMetadataBackup {
@@ -34,22 +31,7 @@ export class PoolMetadataBackup {
const poolDir = `${DIR_XO_POOL_METADATA_BACKUPS}/${schedule.id}/${pool.$id}`
const dir = `${poolDir}/${formatFilenameDate(timestamp)}`
const tasks = Object.values(pool.$xapi.objects.indexes.type.task ?? {})
const pendingTasks = tasks.filter(task => task.status === 'pending')
debug('pool tasks before export', {
total: tasks.length,
pending: pendingTasks.length,
oldestPendingCreated: pendingTasks.map(task => task.created).sort()[0],
})
const stream = (await this._exportPoolMetadata()).body
const startedAt = Date.now()
let bytes = 0
stream.once('data', () => debug('first byte received', { afterMs: Date.now() - startedAt }))
stream.on('data', chunk => (bytes += chunk.length))
stream.once('end', () => debug('stream ended', { bytes, durationMs: Date.now() - startedAt }))
stream.once('error', error => debug('stream errored', { error, bytes, durationMs: Date.now() - startedAt }))
const fileName = `${dir}/data`
const metadata = JSON.stringify(

View File

@@ -10,9 +10,6 @@ export default function createStreamThrottle(rate) {
}
const group = new ThrottleGroup({ rate })
return function throttleStream(stream) {
const throttled = pipeline(stream, group.createThrottle(), noop)
throttled.length = stream.length
throttled.maxStreamLength = stream.maxStreamLength
return throttled
return pipeline(stream, group.createThrottle(), noop)
}
}

View File

@@ -1,58 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { Readable } from 'node:stream'
import createStreamThrottle from './_createStreamThrottle.mjs'
const makeStream = (chunks, props = {}) => {
const stream = Readable.from(chunks)
Object.assign(stream, props)
return stream
}
const readAll = async stream => {
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
return Buffer.concat(chunks.map(c => (typeof c === 'string' ? Buffer.from(c) : c))).toString()
}
describe('createStreamThrottle', () => {
it('rate=0 returns identity, stream data and props intact', async () => {
const throttleStream = createStreamThrottle(0)
const stream = makeStream(['hello world'], { length: 11, maxStreamLength: 20 })
const throttled = throttleStream(stream)
assert.strictEqual(throttled, stream)
assert.strictEqual(throttled.length, 11)
assert.strictEqual(throttled.maxStreamLength, 20)
assert.strictEqual(await readAll(throttled), 'hello world')
})
it('rate>0 passes data through and preserves length and maxStreamLength', async () => {
const throttleStream = createStreamThrottle(1024 * 1024)
const stream = makeStream(['hello world'], { length: 11, maxStreamLength: 20 })
const throttled = throttleStream(stream)
assert.strictEqual(throttled.length, 11)
assert.strictEqual(throttled.maxStreamLength, 20)
assert.strictEqual(await readAll(throttled), 'hello world')
})
it('rate>0 handles undefined length and maxStreamLength', async () => {
const throttleStream = createStreamThrottle(1024 * 1024)
const stream = makeStream(['hello world'])
const throttled = throttleStream(stream)
assert.strictEqual(throttled.length, undefined)
assert.strictEqual(throttled.maxStreamLength, undefined)
assert.strictEqual(await readAll(throttled), 'hello world')
})
// regression: maxStreamLength was lost after throttling, causing S3 to ignore it and cap at 50GB
it('rate>0 preserves maxStreamLength so S3 can compute correct partSize', async () => {
const throttleStream = createStreamThrottle(1024 * 1024)
const stream = makeStream(['hello world'], { maxStreamLength: 11 })
const throttled = throttleStream(stream)
assert.strictEqual(throttled.maxStreamLength, 11)
assert.strictEqual(await readAll(throttled), 'hello world')
})
})

View File

@@ -3,7 +3,6 @@ import { FullRemoteWriter } from '../_writers/FullRemoteWriter.mjs'
import { forkStreamUnpipe } from '../_forkStreamUnpipe.mjs'
import { watchStreamSize } from '../../_watchStreamSize.mjs'
import { AggregatedFullRemoteWriter } from '../_writers/AggregatedFullRemoteWriter.mjs'
import { Task } from '@vates/task'
export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote {
_getRemoteWriters() {
@@ -16,8 +15,7 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
async _run() {
const transferList = await this._computeTransferList(({ mode }) => mode === 'full')
const nbTransferrableVms = transferList.length
let nbTransferredVms = 0
for (const metadata of transferList) {
const stream = this._throttleStream(await this._sourceRemoteAdapter.readFullVmBackup(metadata))
const sizeContainer = watchStreamSize(stream)
@@ -29,7 +27,7 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
stream: forkStreamUnpipe(stream),
// stream will be forked and transformed, it's not safe to attach additional properties to it
streamLength: stream.length,
maxStreamLength: stream.maxStreamLength, // for encrypted destination/source without length
maxStreamLength: stream.maxStreamLength, // on encrypted source
timestamp: metadata.timestamp,
vm: metadata.vm,
vmSnapshot: metadata.vmSnapshot,
@@ -39,10 +37,7 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
)
// for healthcheck
this._tags = metadata.vm.tags
nbTransferredVms++
Task.set('progress', Math.round((nbTransferredVms * 100) / nbTransferrableVms))
}
Task.set('progress', 100)
this._hasTransferredData = transferList.length > 0
}
}

View File

@@ -12,8 +12,6 @@ import { openVhd } from 'vhd-lib'
import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
import { SynchronizedDisk, ThrottledDisk } from '@xen-orchestra/disk-transform'
import { AggregatedIncrementalRemoteWriter } from '../_writers/AggregatedIncrementalRemoteWriter.mjs'
import { Task } from '@vates/task'
import { TaskProgressHandler } from './_TaskProgressHandler.mjs'
const { warn } = createLogger('xo:backups:Incrementalremote')
class IncrementalRemoteVmBackupRunner extends AbstractRemote {
@@ -62,8 +60,7 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
}
async _run() {
const transferList = await this._computeTransferList(({ mode }) => mode === 'delta')
const nbTransferrableVms = transferList.length
let nbTransferredVms = 0
for (const metadata of transferList) {
assert.strictEqual(metadata.mode, 'delta')
const incrementalExport = await this._sourceRemoteAdapter.readIncrementalVmBackup(metadata, undefined, {
@@ -77,7 +74,6 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
for (const key in incrementalExport.disks) {
let disk = incrementalExport.disks[key]
isVhdDifferencing[key] = disk.isDifferencing()
disk.addProgressHandler(new TaskProgressHandler())
disk = new ThrottledDisk(disk, this._throttleGenerator)
incrementalExport.disks[key] = new SynchronizedDisk(disk)
}
@@ -99,7 +95,6 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
writer =>
writer.transfer({
deltaExport: forkDeltaExport(incrementalExport, writer.constructor.name),
includeNonNbdQcow2Fix: metadata.includeNonNbdQcow2Fix,
isVhdDifferencing,
timestamp: metadata.timestamp,
vm: metadata.vm,
@@ -107,14 +102,21 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
}),
'writer.transfer()'
)
// this will update parent name with the needed alias
await this._callWriters(
writer =>
writer.updateUuidAndChain({
isVhdDifferencing,
timestamp: metadata.timestamp,
vdis: incrementalExport.vdis,
}),
'writer.updateUuidAndChain()'
)
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
// for healthcheck
this._tags = metadata.vm.tags
nbTransferredVms++
Task.set('progress', Math.round((nbTransferredVms * 100) / nbTransferrableVms))
}
Task.set('progress', 100)
this._hasTransferredData = transferList.length > 0
}
}

View File

@@ -18,7 +18,6 @@ import {
import { ThrottledDisk, SynchronizedDisk } from '@xen-orchestra/disk-transform'
import { AggregatedIncrementalRemoteWriter } from '../_writers/AggregatedIncrementalRemoteWriter.mjs'
import { AggregatedIncrementalXapiWriter } from '../_writers/AggregatedIncrementalXapiWriter.mjs'
import { TaskProgressHandler } from './_TaskProgressHandler.mjs'
const { debug } = createLogger('xo:backups:IncrementalXapiVmBackup')
@@ -53,7 +52,6 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
let useNbd = false
for (const key in deltaExport.disks) {
let disk = deltaExport.disks[key]
disk.addProgressHandler(new TaskProgressHandler())
isVhdDifferencing[key] = disk.isDifferencing()
if (!isFull && !isVhdDifferencing[key] && key !== exportedVm.$suspend_VDI?.$ref) {
Task.warning('Backup fell back to a full')
@@ -73,10 +71,6 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
writer =>
writer.transfer({
deltaExport: forkDeltaExport(deltaExport, writer.constructor.name),
// freshly exported from XAPI by code that has the fix and the force-full gate
// (writer.checkBaseVdis ran before this transfer), so the chain is verified/forced
// clean; the tip's flag lets the next run skip re-probing
includeNonNbdQcow2Fix: true,
isVhdDifferencing,
timestamp,
vm,
@@ -84,6 +78,15 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
}),
'writer.transfer()'
)
await this._callWriters(
writer =>
writer.updateUuidAndChain({
isVhdDifferencing,
timestamp,
vdis: deltaExport.vdis,
}),
'writer.updateUuidAndChain()'
)
if (isFull) {
await setVmDeltaChainLength(this._xapi, exportedVm.$ref, 0)

View File

@@ -213,35 +213,6 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
vmUuid: vm.uuid,
})
await setVmSnapshotContentKeys(xapi, snapshotRef)
// Verify that setVmOtherConfig actually tagged every disk we expected.
// If the snapshot exposes fewer 'Disk' VBDs than the live VM has, the
// tagging call silently skipped some VDIs and the resulting snapshot
// will be invisible to retention/cleanup on the next run.
const liveDisks = await xapi.VM_getDisks(vm.$ref)
const snapshotDisks = await xapi.VM_getDisks(snapshotRef)
if (liveDisks.length !== snapshotDisks.length) {
const snapshotVbdRefs = await xapi.getField('VM', snapshotRef, 'VBDs')
const snapshotVbds = await xapi.getRecords('VBD', snapshotVbdRefs)
warn('inconsistent snapshot tagging detected', {
vmUuid: vm.uuid,
vmName: vm.name_label,
snapshotRef,
jobId: this._jobId,
timestamp: this.timestamp,
expectedDiskCount: liveDisks.length,
actualDiskCount: snapshotDisks.length,
liveDisks,
snapshotDisks,
snapshotVbds: snapshotVbds.map(vbd => ({
uuid: vbd.uuid,
type: vbd.type,
VDI: vbd.VDI,
currently_attached: vbd.currently_attached,
})),
})
}
const snapshot = await xapi.getRecord('VM', snapshotRef)
await snapshot.set_name_label(this._getSnapshotNameLabel(vm))
// reload data to ensure it is up to date with the new name label

View File

@@ -1,29 +0,0 @@
import { Task } from '@vates/task'
const MAX_DURATION_BETWEEN_PROGRESS_EMIT = 5e3
const MIN_THRESHOLD_PERCENT_BETWEEN_PROGRESS_EMIT = 0.01
export class TaskProgressHandler {
#lastProgressDate
#lastProgressValue
constructor() {
Task.set('progress', 0)
}
async setProgress(progress) {
if (progress < 0 || progress > 1) {
return
}
if (
this.#lastProgressDate !== undefined &&
this.#lastProgressValue !== undefined &&
Date.now() - this.#lastProgressDate < MAX_DURATION_BETWEEN_PROGRESS_EMIT &&
progress - this.#lastProgressValue < MIN_THRESHOLD_PERCENT_BETWEEN_PROGRESS_EMIT
) {
return
}
this.#lastProgressDate = Date.now()
this.#lastProgressValue = progress
Task.set('progress', Math.round(progress * 100))
}
done() {}
}

View File

@@ -22,7 +22,7 @@ export class AggregatedIncrementalRemoteWriter extends AbstractAggregatedRemoteW
}
// update the parameter with our best candidate
// that can be an empty map
baseUuidToSrcVdi.forEach((_, key) => {
baseUuidToSrcVdi.forEach(key => {
if (!selectedCopy.has(key)) {
baseUuidToSrcVdi.delete(key)
}
@@ -50,8 +50,13 @@ export class AggregatedIncrementalRemoteWriter extends AbstractAggregatedRemoteW
await Promise.all(this.writers.map(writer => writer.prepare({ isFull })))
}
// write only on the main writer
transfer(args) {
return this.mainWriter.transfer(args)
transfer({ isVhdDifferencing, timestamp, deltaExport, vm, vmSnapshot }) {
return this.mainWriter.transfer({ isVhdDifferencing, timestamp, deltaExport, vm, vmSnapshot })
}
// chain only on the main writer
updateUuidAndChain({ isVhdDifferencing, vdis }) {
return this.mainWriter.updateUuidAndChain({ isVhdDifferencing, vdis })
}
// remove the backups and remove the entries

View File

@@ -25,7 +25,7 @@ export class AggregatedIncrementalXapiWriter extends AbstractAggregatedXapiWrite
}
// update the parameter with our best candidate
// that can be an empty map
baseUuidToSrcVdi.forEach((_, key) => {
baseUuidToSrcVdi.forEach(key => {
if (!selectedCopy.has(key)) {
baseUuidToSrcVdi.delete(key)
}
@@ -59,6 +59,11 @@ export class AggregatedIncrementalXapiWriter extends AbstractAggregatedXapiWrite
return this.mainWriter.transfer(args)
}
// chain only on the main writer
updateUuidAndChain(args) {
return this.mainWriter.updateUuidAndChain(args)
}
// remove the backups and remove the entries
async cleanup() {
debug('cleanup')

View File

@@ -2,11 +2,11 @@ import assert from 'node:assert'
import mapValues from 'lodash/mapValues.js'
import { asyncEach } from '@vates/async-each'
import { asyncMap } from '@xen-orchestra/async-map'
import { chainVhd, openVhd } from 'vhd-lib'
import { createLogger } from '@xen-orchestra/log'
import { decorateClass } from '@vates/decorate-with'
import { defer } from 'golike-defer'
import { dirname, basename } from 'node:path'
import { relativeFromFile } from '@xen-orchestra/fs/path'
import { Task } from '@vates/task'
import { formatFilenameDate } from '../../_filenameDate.mjs'
@@ -16,18 +16,15 @@ import { MixinRemoteWriter } from './_MixinRemoteWriter.mjs'
import { AbstractIncrementalWriter } from './_AbstractIncrementalWriter.mjs'
import { checkVhd } from './_checkVhd.mjs'
import { packUuid } from './_packUuid.mjs'
import { openDiskChain } from '@xen-orchestra/backup-archive/disks'
import { VDI_FORMAT_QCOW2 } from '@xen-orchestra/xapi'
import { diskHasData } from './_diskHasData.mjs'
import { Disposable } from 'promise-toolbox'
const { warn } = createLogger('xo:backups:DeltaBackupWriter')
export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrementalWriter) {
#parentVdiPaths
#parentUuids
#vhds
async checkBaseVdis(baseUuidToSrcVdi) {
this.#parentVdiPaths = {}
this.#parentUuids = {}
const { handler } = this._adapter
const adapter = this._adapter
@@ -35,7 +32,6 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
await asyncMap(baseUuidToSrcVdi, async ([baseUuid, srcVdiUuid]) => {
let parentDestPath
let parentUuid
const vhdDir = `${vdisDir}/${srcVdiUuid}`
try {
const vhds = await handler.list(vhdDir, {
@@ -51,7 +47,6 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
try {
if (await adapter.isMergeableParent(packedBaseUuid, path)) {
parentDestPath = path
parentUuid = packedBaseUuid
}
} catch (error) {
warn('checkBaseVdis', { error })
@@ -64,77 +59,11 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
if (parentDestPath === undefined) {
baseUuidToSrcVdi.delete(baseUuid)
} else {
try {
// A delta is only chained onto a qcow2 base when NBD is enabled; without NBD a qcow2
// export falls back to a full, so there is no suspect base to worry about.
if (this._settings.preferNbd && !(await this.#isBaseChainable(parentDestPath))) {
Task.warning('forcing a full: qcow2 base predates the non-NBD qcow2 fix and looks corrupt', {
parentDestPath,
})
baseUuidToSrcVdi.delete(baseUuid)
return
}
this.#parentVdiPaths[vhdDir] = parentDestPath
this.#parentUuids[vhdDir] = parentUuid
} catch (error) {
warn('checkBaseVdis: unexpected error, forcing a full', { error, parentDestPath })
baseUuidToSrcVdi.delete(baseUuid)
}
this.#parentVdiPaths[vhdDir] = parentDestPath
}
})
}
// Decide whether it is safe to chain a delta onto `parentDestPath` (the chain tip).
// A qcow2 full exported before the DiskLargerBlock 20260724 fix can be silently corrupt (block 0
// valid, following allocated blocks all zero); chaining a delta onto it would extend a
// broken chain, so we detect that case and let the caller fall back to a full.
async #isBaseChainable(parentDestPath) {
// parentDestPath is the chain tip = most-recent backup; its filename is stable under
// merges (merges rename the OLDER disks), so unlike the root it maps reliably to its
// metadata json.
const parentDate = basename(parentDestPath).replace(/\.(alias\.)?vhd$/, '')
let metadata
try {
metadata = await this._adapter.readVmBackupMetadata(`${this._vmBackupDir}/${parentDate}.json`)
} catch (error) {
warn('checkBaseVdis: cannot read parent metadata, will probe', { error, parentDestPath })
}
if (metadata?.includeNonNbdQcow2Fix === true) {
// written by code that has the fix and this gate, so its chain was already
// verified/forced clean — no disk open needed
// we can't have includeNonNbdQcow2Fix = false since it would have restarted a clean chain
return true
}
const isQcow2 =
metadata === undefined ||
Object.values(metadata.vdis ?? {}).some(vdi => vdi?.sm_config?.['image-format'] === VDI_FORMAT_QCOW2)
if (!isQcow2) {
// only qcow2 exports can carry this corruption
return true
}
return this.#baseHasData(parentDestPath)
}
// Probe the first data blocks of the chain root for the DiskLargerBlock corruption
// signature. Returns false only when enough allocated blocks past the first are all zero.
async #baseHasData(parentDestPath) {
const { handler } = this._adapter
// open the chain (with block allocation tables) and probe its root full. This only runs
// once per chain — afterwards the includeNonNbdQcow2Fix flag on the tip skips it — so
// reading the deltas' BATs here is negligible and lets us reuse the root disk directly.
const chain = await openDiskChain({ handler, path: parentDestPath })
try {
const root = chain.getRootDisk()
if (root === undefined) {
throw new Error(`can't resolve root of chain ${parentDestPath}`)
}
return await diskHasData(root)
} finally {
await chain.close()
}
}
async beforeBackup() {
await super.beforeBackup()
return this._cleanVm({ merge: true, remove: true })
@@ -197,6 +126,48 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
}
}
async updateUuidAndChain({ isVhdDifferencing, vdis }) {
assert.notStrictEqual(
this.#vhds,
undefined,
'_transfer must be called before updateUuidAndChain for incremental backups'
)
const parentVdiPaths = this.#parentVdiPaths
const { handler } = this._adapter
const vhds = this.#vhds
await asyncEach(Object.entries(vdis), async ([id, vdi]) => {
const isDifferencing = isVhdDifferencing[id]
const path = `${this._vmBackupDir}/${vhds[id]}`
if (isDifferencing) {
assert.notStrictEqual(
parentVdiPaths,
undefined,
'checkbasevdi must be called before updateUuidAndChain for incremental backups'
)
const parentPath = parentVdiPaths[dirname(path)]
// we are in a incremental backup
// we already computed the chain in checkBaseVdis
assert.notStrictEqual(parentPath, undefined, 'A differential VHD must have a parent')
// forbid any kind of loop
assert.ok(basename(parentPath) < basename(path), `vhd must be sorted to be chained`)
// re-chainVhd is mandatory
// since the parent may be a alias or not
// and the child may be the other
await chainVhd(handler, parentPath, handler, path)
}
// set the correct UUID in the VHD if needed
await Disposable.use(openVhd(handler, path), async vhd => {
if (!vhd.footer.uuid.equals(packUuid(vdi.uuid))) {
vhd.footer.uuid = packUuid(vdi.uuid)
await vhd.readBlockAllocationTable() // required by writeFooter()
await vhd.writeFooter()
}
})
})
}
async _deleteOldEntries() {
const adapter = this._adapter
const oldEntries = this._oldEntries
@@ -207,7 +178,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
}
}
async _transfer($defer, { includeNonNbdQcow2Fix, isVhdDifferencing, timestamp, deltaExport, vm, vmSnapshot }) {
async _transfer($defer, { isVhdDifferencing, timestamp, deltaExport, vm, vmSnapshot }) {
const adapter = this._adapter
const job = this._job
const scheduleId = this._schedule.id
@@ -215,8 +186,10 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
const jobId = job.id
const handler = adapter.handler
const filenameDate = formatFilenameDate(timestamp)
const vhds = mapValues(
const basename = formatFilenameDate(timestamp)
// update this.#vhds before eventually skipping transfer, so that
// updateUuidAndChain has all the mandatory data
const vhds = (this.#vhds = mapValues(
deltaExport.vdis,
vdi =>
`vdis/${jobId}/${
@@ -225,8 +198,8 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
// don't do delta for it
vdi.uuid
: vdi.$snapshot_of$uuid
}/${adapter.getVhdFileName(filenameDate)}`
)
}/${adapter.getVhdFileName(basename)}`
))
let metadataContent = await this._isAlreadyTransferred(timestamp)
if (metadataContent !== undefined) {
@@ -236,11 +209,6 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
}
metadataContent = {
// marks whether the disk data is known-good w.r.t. the non-NBD qcow2 corruption bug.
// A fresh XAPI export by fixed code passes `true`; the mirror runner propagates the
// source backup's value (copying corrupt source data must NOT become "fixed"). Its
// absence flags a potentially-corrupt older disk (used by cleanVm and the force-full gate).
includeNonNbdQcow2Fix,
isVhdDifferencing,
jobId,
mode: job.mode,
@@ -261,26 +229,13 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
Object.entries(deltaExport.disks),
async ([diskRef, disk]) => {
const path = `${this._vmBackupDir}/${vhds[diskRef]}`
const vdi = deltaExport.vdis[diskRef]
let parentUuid, parentPath
if (isVhdDifferencing[diskRef]) {
const parentDestPath = this.#parentVdiPaths[dirname(path)]
parentUuid = this.#parentUuids[dirname(path)]
assert.notStrictEqual(parentDestPath, undefined, 'A differential VHD must have a parent')
// forbid any kind of loop
assert.ok(basename(parentDestPath) < basename(path), `vhd must be sorted to be chained`)
parentPath = relativeFromFile(path, parentDestPath)
}
const transferred = await adapter.writeVhd(path, disk, {
size += await adapter.writeVhd(path, disk, {
// no checksum for VHDs, because they will be invalidated by
// merges and chains
checksum: false,
validator: tmpPath => checkVhd(handler, tmpPath),
writeBlockConcurrency: this._config.writeBlockConcurrency,
uuid: packUuid(vdi.uuid),
parentUuid,
parentPath,
})
size += transferred
},
{
concurrency: settings.diskPerVmConcurrency,

View File

@@ -1,4 +1,3 @@
import assert from 'node:assert'
import humanFormat from 'human-format'
import { asyncMapSettled } from '@xen-orchestra/async-map'
@@ -21,7 +20,6 @@ import {
DATETIME,
VM_UUID,
CONTENT_KEY,
INCLUDE_NON_NBD_QCOW2_FIX,
resetVmOtherConfig,
} from '../../_otherConfig.mjs'
import { formatFilenameDate } from '../../_filenameDate.mjs'
@@ -29,9 +27,8 @@ import { XapiDiskSource } from '@xen-orchestra/xapi'
import { asyncEach } from '@vates/async-each'
import { createLogger } from '@xen-orchestra/log'
import { VM_POWER_STATE } from '@vates/types'
import { diskHasData } from './_diskHasData.mjs'
const { debug, warn } = createLogger('xo:backups:IncrementalXapiWriter')
const { debug } = createLogger('xo:backups:IncrementalXapiWriter')
export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
// Map of source VDI UUID (COPY_OF) → validated active VDI on the target SR.
@@ -96,45 +93,6 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
)
}
if (target !== undefined) {
// A delta is only chained onto a qcow2 base when NBD is enabled; without NBD a qcow2
// export falls back to a full, so there is no suspect base.
if (this._settings.preferNbd) {
// Every copy written by fixed code carries the flag, so the marker on the most recent
// link is enough: if it is there, this chain was already verified (or re-based) by a
// run that had the fix, and older links need no re-check.
const suspect = target.other_config[INCLUDE_NON_NBD_QCOW2_FIX] !== 'true'
if (suspect) {
const chain = this.#diskReplicaSnapshots(target)
// probe the OLDEST link (least-masked view of the corruption — a later delta could
// have overwritten the corrupt blocks and hidden the damage on a more recent snapshot).
// Re-replicating a full is expensive, so only force one when it actually looks corrupt.
let base = target
assert.notStrictEqual(base.other_config[DATETIME], undefined)
for (const vdi of chain) {
const datetime = vdi.other_config[DATETIME]
assert.notStrictEqual(datetime, undefined)
if (datetime < base.other_config[DATETIME]) {
base = vdi
}
}
let chainable
try {
chainable = await this.#baseVdiHasData(base.$ref)
} catch (error) {
// can't read the base to decide → don't chain onto it
warn('checkBaseVdis: error probing replicated base, forcing a full', { error, baseVdi: base.uuid })
chainable = false
}
if (!chainable) {
Task.warning('forcing a full: replicated qcow2 chain predates the non-NBD qcow2 fix and looks corrupt', {
baseVdi: base.uuid,
})
// drop it so neither the snapshot nor the legacy fallback reuses it → full
baseUuidToSrcVdi.delete(baseUuid)
continue
}
}
}
snapshotCandidates.set(baseUuid, target)
}
}
@@ -175,42 +133,6 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
}
}
}
// All replica snapshots of a given disk share the same active target VDI ($snapshot_of). Return
// them (same job+vm) so the caller can inspect the whole chain: any link missing the fix marker
// makes the chain suspect, and the oldest link (by DATETIME) is the least-masked base to probe.
// Scoped by $snapshot_of, so history before a CR VM recreation (which resets it) is not included.
#diskReplicaSnapshots(target) {
const activeUuid = target.$snapshot_of?.uuid
const snapshots = []
for (const vdi of this._sr.$VDIs) {
if (
vdi?.managed &&
vdi?.is_a_snapshot &&
vdi.$snapshot_of?.uuid === activeUuid &&
vdi.other_config[JOB_ID] === this._job.id &&
vdi.other_config[VM_UUID] === this._vmUuid
) {
snapshots.push(vdi)
}
}
return snapshots
}
// Probe a replicated base VDI for the DiskLargerBlock corruption signature (block 0 valid,
// following allocated blocks all zero). Uses an export (not NBD): block flow is guaranteed
// to be in index order, so block 0 comes first and inspecting the first few blocks is enough.
// Returns false only when no allocated non-zero block is found among the first ones.
async #baseVdiHasData(vdiRef) {
const source = new XapiDiskSource({ xapi: this._sr.$xapi, vdiRef, preferNbd: false })
try {
await source.init()
return await diskHasData(source)
} finally {
await source.close()
}
}
/**
* 6.3+ snapshot-based validation: for each snapshot candidate, check whether
* the active VDI has diverged from the snapshot. Returns a baseVdisBySourceUuid
@@ -313,6 +235,9 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
return { baseVdisBySourceUuid, targetVmRef }
}
updateUuidAndChain() {
// nothing to do, the chaining is not modified in this case
}
prepare({ isFull }) {
// create the task related to this export and ensure all methods are called in this context
const task = new Task({
@@ -418,10 +343,6 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
if (baseDeltaVdiUuid !== undefined) {
// reuse the validated mapping built by checkBaseVdis
vdi.baseVdi = this.#baseVdisBySourceUuid.get(baseDeltaVdiUuid)
// BASE_DELTA_VDI is set, so the export is differencing. Without a base to clone,
// importIncrementalVm would create a blank VDI and write only the changed blocks
// into it, silently producing a corrupt replica: fail the job instead.
assert.notStrictEqual(vdi.baseVdi, undefined, `no validated target base for ${baseDeltaVdiUuid}`)
} else {
// first replication of this disk (full, no base)
vdi.baseVdi = undefined

View File

@@ -5,6 +5,10 @@ export class AbstractIncrementalWriter extends AbstractWriter {
throw new Error('Not implemented')
}
updateUuidAndChain() {
throw new Error('Not implemented')
}
cleanup() {
throw new Error('Not implemented')
}
@@ -16,6 +20,8 @@ export class AbstractIncrementalWriter extends AbstractWriter {
async transfer({ deltaExport, ...other }) {
try {
return await this._transfer({ deltaExport, ...other })
} catch (err) {
console.error({ err })
} finally {
// ensure all sources are properly closed
for (const disk of Object.values(deltaExport.disks)) {

View File

@@ -1,26 +0,0 @@
// Probe a disk for the DiskLargerBlock corruption signature (block 0 valid,
// following allocated blocks all zero). Uses an export (not NBD): block flow is guaranteed
// to be in index order, so block 0 comes first and inspecting the first few blocks is enough.
// Returns false only when no allocated non-zero block is found among the first ones.
export async function diskHasData(source) {
const empty = Buffer.alloc(source.getBlockSize(), 0)
const MAX_EMPTY = 10
let nbEmpty = 0
// diskBlocks() closes the source when the iteration ends or is broken out of
for await (const { index, data } of source.diskBlocks()) {
if (index === 0) {
// block 0 stays valid even in the corruption signature — skip it
continue
}
if (!data.equals(empty)) {
// an allocated block holds real data → not corrupt
return true
}
if (++nbEmpty >= MAX_EMPTY) {
break
}
}
// no allocated non-zero block among the first ones → looks corrupt (or empty; a full is cheap then)
return false
}

View File

@@ -1,16 +1,6 @@
import { TaskProgressHandler } from './_runners/_vmRunners/_TaskProgressHandler.mjs'
export function watchStreamSize(stream, container = { size: 0 }) {
const progressHandler = container.progressHandler ?? new TaskProgressHandler()
stream.on('data', data => {
container.size += data.length
if (stream.length) {
// empty for xva exported by xapi
progressHandler.setProgress(container.size / stream.length)
}
})
stream.on('finish', () => {
progressHandler.setProgress(1)
})
stream.pause()
return container

View File

@@ -230,6 +230,7 @@ Settings are described in [`@xen-orchestra/backups/\_runners/VmsXapi.mjs``](http
- `checkBaseVdis(baseUuidToSrcVdi, baseVm)`
- `prepare({ isFull })`
- `transfer({ timestamp, deltaExport, sizeContainers })`
- `updateUuidAndChain({ isVhdDifferencing, vdis })`
- `cleanup()`
- `healthCheck()` // is not executed if no health check sr or tag doesn't match
- **Full**

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.73.10",
"version": "0.72.1",
"engines": {
"node": ">=14.18"
},
@@ -25,19 +25,19 @@
"@vates/compose": "^2.1.0",
"@vates/decorate-with": "^2.1.0",
"@vates/disposable": "^0.1.6",
"@vates/fuse-vhd": "^2.1.3",
"@vates/generator-toolbox": "^1.1.3",
"@vates/nbd-client": "^3.5.0",
"@vates/fuse-vhd": "^2.1.2",
"@vates/generator-toolbox": "^1.1.2",
"@vates/nbd-client": "^3.4.0",
"@vates/parse-duration": "^0.1.1",
"@vates/task": "^0.7.0",
"@vates/types": "^1.30.1",
"@vates/types": "^1.24.0",
"@xen-orchestra/async-map": "^0.1.3",
"@xen-orchestra/disk-transform": "^1.3.3",
"@xen-orchestra/fs": "^4.9.3",
"@xen-orchestra/disk-transform": "^1.2.3",
"@xen-orchestra/fs": "^4.8.0",
"@xen-orchestra/log": "^0.7.2",
"@xen-orchestra/qcow2": "^1.3.1",
"@xen-orchestra/qcow2": "^1.3.0",
"@xen-orchestra/template": "^0.1.1",
"@xen-orchestra/backup-archive": "^1.1.1",
"@xen-orchestra/backup-archive": "^1.0.0",
"app-conf": "^3.0.0",
"compare-versions": "^6.0.0",
"d3-time-format": "^4.1.0",
@@ -55,8 +55,8 @@
"tar": "^7.5.3",
"uuid": "^9.0.0",
"value-matcher": "^0.2.0",
"vhd-lib": "^4.16.1",
"xen-api": "^4.8.0",
"vhd-lib": "^4.16.0",
"xen-api": "^4.7.7",
"yazl": "^2.5.1"
},
"devDependencies": {
@@ -67,7 +67,7 @@
"typescript": "^5.9.3"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^8.10.2"
"@xen-orchestra/xapi": "^8.7.2"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

@@ -4,10 +4,22 @@ import { fork } from 'child_process'
const { warn } = createLogger('xo:backups:backupWorker')
const PATH = new URL('_backupWorker.mjs', import.meta.url).pathname
const DEFAULT_INSPECTOR_PORT = 9229
export function runBackupWorker(params, onLog) {
return new Promise((resolve, reject) => {
const worker = fork(PATH, [])
// run Node inspector on port+1 if --inspect or --inspect-brk
const inspectArg = process.execArgv.find(arg => arg.startsWith('--inspect') || arg.startsWith('--inspect-brk'))
const execArgv = inspectArg
? [
inspectArg.replace(/^(--inspect(-brk)?)(=([^:]+:)?(\d+))?$/, (_, prefix, brk, _fullMatch, host, port) => {
const basePort = port ? parseInt(port) : DEFAULT_INSPECTOR_PORT
return `${prefix}=${host || ''}${basePort + 1}`
}),
]
: []
const worker = fork(PATH, [], { execArgv })
worker.on('exit', (code, signal) => reject(new Error(`worker exited with code ${code} and signal ${signal}`)))
worker.on('error', reject)

View File

@@ -18,7 +18,7 @@
"preferGlobal": true,
"dependencies": {
"golike-defer": "^0.5.1",
"xen-api": "^4.8.0"
"xen-api": "^4.7.7"
},
"scripts": {
"postversion": "npm publish"

View File

@@ -3,8 +3,8 @@ $ xo-disk-cli --help
Usage: xo-disk-cli <command> <handler-url> <path> [options]
Commands:
info Show disk info (virtual size, uid, parent uid, block size); --chain shows the full parent chain, --size adds the size on disk
list List all disks at a path and display their properties in a table; --size adds the size on disk
info Show disk info (virtual size, uid, parent uid, block size)
list List all disks at a path and display their properties in a table
transform Convert a disk to another format and write to stdout (raw | vhd | qcow2)
```
@@ -15,7 +15,7 @@ Commands:
Display metadata for a single disk file.
```
xo-disk-cli info <handler-url> <disk-path> [--chain] [--size]
xo-disk-cli info <handler-url> <disk-path>
```
Output includes: UID, parent UID (for differencing disks), virtual size, and block size.
@@ -29,66 +29,16 @@ Disk info: /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/snapshot.vhd
Block size: 2.00 MiB (2097152 bytes)
```
By default the disk is opened without reading its block allocation table, which
is cheap but does not allow computing the size on disk. Pass `--size` to read
the block allocation table and print the size on disk as an extra line:
```
$ xo-disk-cli info file:///mnt/backups /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/snapshot.vhd --size
Disk info: /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/snapshot.vhd
UID: <uuid>
Parent UID: <parent-uuid>
Virtual size: 8.00 GiB (8589934592 bytes)
Block size: 2.00 MiB (2097152 bytes)
Size on disk: 128.00 MiB (134217728 bytes)
```
With `--chain`, the disk's full parent chain is opened and each disk is printed
from the root (base) disk down to the given leaf disk. `--chain` and `--size`
can be combined:
```
$ xo-disk-cli info file:///mnt/backups /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/snapshot.vhd --chain
Disk chain: /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/snapshot.vhd (2 disks, root → leaf)
Disk info: /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/base.vhd
UID: <base-uuid>
Parent UID: (none)
Virtual size: 8.00 GiB (8589934592 bytes)
Block size: 2.00 MiB (2097152 bytes)
Disk info: /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/snapshot.vhd
UID: <uuid>
Parent UID: <base-uuid>
Virtual size: 8.00 GiB (8589934592 bytes)
Block size: 2.00 MiB (2097152 bytes)
```
### `list`
List all disk files found at a directory path and display their properties in a table. Disks are sorted so that each child appears directly after its parent. When a disk's parent is the row immediately above, the parent UID column shows `↑` for quick chain health assessment.
```
xo-disk-cli list <handler-url> <dir-path> [--size]
xo-disk-cli list <handler-url> <dir-path>
```
By default disks are opened without reading their block allocation table (cheap),
so the "Size on disk" column is omitted:
```
$ xo-disk-cli list file:///mnt/backups /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/
┌──────────────┬──────────────────────────────────────┬──────────────┬─────────────┬──────────────────────────────────────┐
│ File │ UID │ Virtual size │ Differencing │ Parent UID │
├──────────────┼──────────────────────────────────────┼──────────────┼─────────────┼──────────────────────────────────────┤
│ base.vhd │ xxxxxxxx-... │ 8.00 GiB │ no │ (none) │
│ snapshot.vhd │ yyyyyyyy-... │ 8.00 GiB │ yes │ ↑ │
└──────────────┴──────────────────────────────────────┴──────────────┴─────────────┴──────────────────────────────────────┘
```
Pass `--size` to read each disk's block allocation table and add the "Size on disk" column:
```
$ xo-disk-cli list file:///mnt/backups /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/ --size
┌──────────────┬──────────────────────────────────────┬─────────────┬──────────────┬─────────────┬──────────────────────────────────────┐
│ File │ UID │ Size on disk│ Virtual size │ Differencing │ Parent UID │
├──────────────┼──────────────────────────────────────┼─────────────┼──────────────┼─────────────┼──────────────────────────────────────┤

View File

@@ -21,8 +21,8 @@ $ xo-disk-cli --help
Usage: xo-disk-cli <command> <handler-url> <path> [options]
Commands:
info Show disk info (virtual size, uid, parent uid, block size); --chain shows the full parent chain, --size adds the size on disk
list List all disks at a path and display their properties in a table; --size adds the size on disk
info Show disk info (virtual size, uid, parent uid, block size)
list List all disks at a path and display their properties in a table
transform Convert a disk to another format and write to stdout (raw | vhd | qcow2)
```
@@ -33,7 +33,7 @@ Commands:
Display metadata for a single disk file.
```
xo-disk-cli info <handler-url> <disk-path> [--chain] [--size]
xo-disk-cli info <handler-url> <disk-path>
```
Output includes: UID, parent UID (for differencing disks), virtual size, and block size.
@@ -47,66 +47,16 @@ Disk info: /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/snapshot.vhd
Block size: 2.00 MiB (2097152 bytes)
```
By default the disk is opened without reading its block allocation table, which
is cheap but does not allow computing the size on disk. Pass `--size` to read
the block allocation table and print the size on disk as an extra line:
```
$ xo-disk-cli info file:///mnt/backups /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/snapshot.vhd --size
Disk info: /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/snapshot.vhd
UID: <uuid>
Parent UID: <parent-uuid>
Virtual size: 8.00 GiB (8589934592 bytes)
Block size: 2.00 MiB (2097152 bytes)
Size on disk: 128.00 MiB (134217728 bytes)
```
With `--chain`, the disk's full parent chain is opened and each disk is printed
from the root (base) disk down to the given leaf disk. `--chain` and `--size`
can be combined:
```
$ xo-disk-cli info file:///mnt/backups /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/snapshot.vhd --chain
Disk chain: /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/snapshot.vhd (2 disks, root → leaf)
Disk info: /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/base.vhd
UID: <base-uuid>
Parent UID: (none)
Virtual size: 8.00 GiB (8589934592 bytes)
Block size: 2.00 MiB (2097152 bytes)
Disk info: /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/snapshot.vhd
UID: <uuid>
Parent UID: <base-uuid>
Virtual size: 8.00 GiB (8589934592 bytes)
Block size: 2.00 MiB (2097152 bytes)
```
### `list`
List all disk files found at a directory path and display their properties in a table. Disks are sorted so that each child appears directly after its parent. When a disk's parent is the row immediately above, the parent UID column shows `↑` for quick chain health assessment.
```
xo-disk-cli list <handler-url> <dir-path> [--size]
xo-disk-cli list <handler-url> <dir-path>
```
By default disks are opened without reading their block allocation table (cheap),
so the "Size on disk" column is omitted:
```
$ xo-disk-cli list file:///mnt/backups /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/
┌──────────────┬──────────────────────────────────────┬──────────────┬─────────────┬──────────────────────────────────────┐
│ File │ UID │ Virtual size │ Differencing │ Parent UID │
├──────────────┼──────────────────────────────────────┼──────────────┼─────────────┼──────────────────────────────────────┤
│ base.vhd │ xxxxxxxx-... │ 8.00 GiB │ no │ (none) │
│ snapshot.vhd │ yyyyyyyy-... │ 8.00 GiB │ yes │ ↑ │
└──────────────┴──────────────────────────────────────┴──────────────┴─────────────┴──────────────────────────────────────┘
```
Pass `--size` to read each disk's block allocation table and add the "Size on disk" column:
```
$ xo-disk-cli list file:///mnt/backups /xo-vm-backups/<vm-uuid>/vdis/<vdi-uuid>/ --size
┌──────────────┬──────────────────────────────────────┬─────────────┬──────────────┬─────────────┬──────────────────────────────────────┐
│ File │ UID │ Size on disk│ Virtual size │ Differencing │ Parent UID │
├──────────────┼──────────────────────────────────────┼─────────────┼──────────────┼─────────────┼──────────────────────────────────────┤

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/disk-cli",
"version": "2.2.0",
"version": "2.0.0",
"description": "CLI tool to inspect and manage disks in backup repositories",
"type": "module",
"bin": {
@@ -20,13 +20,13 @@
},
"dependencies": {
"@vates/async-each": "^1.0.3",
"@xen-orchestra/backups": "^0.73.10",
"@xen-orchestra/backup-archive": "^1.1.1",
"@xen-orchestra/disk-transform": "^1.3.3",
"@xen-orchestra/fs": "^4.9.3",
"@xen-orchestra/qcow2": "^1.3.1",
"@xen-orchestra/backups": "^0.72.1",
"@xen-orchestra/backup-archive": "^1.0.0",
"@xen-orchestra/disk-transform": "^1.2.3",
"@xen-orchestra/fs": "^4.8.0",
"@xen-orchestra/qcow2": "^1.3.0",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.16.1"
"vhd-lib": "^4.16.0"
},
"devDependencies": {
"@types/node": "^22.3.0",

View File

@@ -1,84 +1,22 @@
import Disposable from 'promise-toolbox/Disposable'
import { getSyncedHandler, type RemoteHandler } from '@xen-orchestra/fs'
import { openDisposableDisk, openDiskChain, RemoteDisk, DiskDisposable } from '@xen-orchestra/backup-archive/disks'
import { getSyncedHandler } from '@xen-orchestra/fs'
import { openDisposableDisk, RemoteDisk } from '@xen-orchestra/backup-archive/disks'
import { formatBytes } from '../utils.mjs'
type DiskInfoLine = {
path: string
uid: string
parentUid: string | null
virtualSize: number
blockSize: number
// Only computed with --size; requires reading the block allocation table.
sizeOnDisk?: number
}
function readDiskInfo(disk: RemoteDisk, path: string, showSize: boolean): DiskInfoLine {
const isDifferencing = disk.isDifferencing()
return {
path,
uid: disk.getUuid(),
parentUid: isDifferencing ? disk.getParentUuid() : null,
virtualSize: disk.getVirtualSize(),
blockSize: disk.getBlockSize(),
sizeOnDisk: showSize ? disk.getSizeOnDisk() : undefined,
}
}
function printDiskInfo(info: DiskInfoLine): void {
console.log(`Disk info: ${info.path}`)
console.log(` UID: ${info.uid}`)
console.log(` Parent UID: ${info.parentUid ?? '(none)'}`)
console.log(` Virtual size: ${formatBytes(info.virtualSize)} (${info.virtualSize} bytes)`)
console.log(` Block size: ${formatBytes(info.blockSize)} (${info.blockSize} bytes)`)
if (info.sizeOnDisk !== undefined) {
console.log(` Size on disk: ${formatBytes(info.sizeOnDisk)} (${info.sizeOnDisk} bytes)`)
}
}
// Opens the full chain (root → leaf) as a disposable, reusing the same chain
// walker as the `transform` command.
async function openDisposableChain(
handler: RemoteHandler,
path: string,
ignoreBlockIndexes: boolean
): Promise<DiskDisposable> {
const chain = await openDiskChain({ handler, path, ignoreBlockIndexes })
return { value: chain, dispose: () => chain.close() }
}
export async function infoCommand(handlerUrl: string, diskPath: string, extraArgs: string[]): Promise<void> {
const showChain = extraArgs.includes('--chain')
const showSize = extraArgs.includes('--size')
// Reading the block allocation table is costly, so only do it when the size on
// disk is requested; every other field comes from the footer/header.
const ignoreBlockIndexes = !showSize
export async function infoCommand(handlerUrl: string, diskPath: string, _extraArgs: string[]): Promise<void> {
await Disposable.use(getSyncedHandler({ url: handlerUrl }), async handler => {
if (!showChain) {
const info = await Disposable.use(
openDisposableDisk({ handler, path: diskPath, ignoreBlockIndexes }),
(disk: RemoteDisk) => readDiskInfo(disk, diskPath, showSize)
)
printDiskInfo(info)
return
}
await Disposable.use(openDisposableDisk({ handler, path: diskPath }), async (disk: RemoteDisk) => {
const isDifferencing = disk.isDifferencing()
const virtualSize = disk.getVirtualSize()
const blockSize = disk.getBlockSize()
const uid = disk.getUuid()
const parentUid = isDifferencing ? disk.getParentUuid() : null
// Resolve the full lineage (root → leaf), then print each disk's info.
const paths = await Disposable.use(openDisposableChain(handler, diskPath, ignoreBlockIndexes), chain =>
chain.getPaths()
)
console.log(`Disk chain: ${diskPath} (${paths.length} disk${paths.length === 1 ? '' : 's'}, root → leaf)\n`)
for (const [index, path] of paths.entries()) {
const info = await Disposable.use(openDisposableDisk({ handler, path, ignoreBlockIndexes }), (disk: RemoteDisk) =>
readDiskInfo(disk, path, showSize)
)
printDiskInfo(info)
if (index < paths.length - 1) {
console.log()
}
}
console.log(`Disk info: ${diskPath}`)
console.log(` UID: ${uid}`)
console.log(` Parent UID: ${parentUid ?? '(none)'}`)
console.log(` Virtual size: ${formatBytes(virtualSize)} (${virtualSize} bytes)`)
console.log(` Block size: ${formatBytes(blockSize)} (${blockSize} bytes)`)
})
})
}

View File

@@ -3,18 +3,13 @@ import { getSyncedHandler } from '@xen-orchestra/fs'
import { isDisk, openDisposableDisk, RemoteDisk } from '@xen-orchestra/backup-archive/disks'
import { formatBytes, renderTable } from '../utils.mjs'
import { asyncEach } from '@vates/async-each'
// The "Size on disk" column is only shown with --size: computing it requires
// reading the block allocation table of every disk, which is costly.
function buildHeaders(showSize: boolean): string[] {
return ['File', 'UID', ...(showSize ? ['Size on disk'] : []), 'Virtual size', 'Differencing', 'Parent UID']
}
const HEADERS = ['File', 'UID', 'Size on disk', 'Virtual size', 'Differencing', 'Parent UID']
export type OkDiskInfo = {
ok: true
filename: string
uid: string
sizeOnDisk?: string
sizeOnDisk: string
virtualSize: string
differencing: boolean
parentUid: string | null
@@ -59,10 +54,9 @@ export function sortDisks(disks: DiskInfo[]): DiskInfo[] {
return [...sorted, ...errDisks]
}
export function toTableRow(disk: DiskInfo, prevDisk: DiskInfo | undefined, showSize: boolean): string[] {
export function toTableRow(disk: DiskInfo, prevDisk: DiskInfo | undefined): string[] {
if (!disk.ok) {
// filename + error message, then a placeholder for every remaining column.
return [disk.filename, `(error: ${disk.error})`, ...Array(showSize ? 4 : 3).fill('-')]
return [disk.filename, `(error: ${disk.error})`, '-', '-', '-', '-']
}
const parentIsAbove = disk.differencing && prevDisk?.ok && prevDisk.uid === disk.parentUid
@@ -70,19 +64,17 @@ export function toTableRow(disk: DiskInfo, prevDisk: DiskInfo | undefined, showS
return [
disk.filename,
disk.uid,
...(showSize ? [disk.sizeOnDisk ?? '-'] : []),
disk.sizeOnDisk,
disk.virtualSize,
disk.differencing ? 'yes' : 'no',
disk.differencing ? (parentIsAbove ? '↑' : (disk.parentUid ?? '')) : '(none)',
]
}
export async function listCommand(handlerUrl: string, dirPath: string, extraArgs: string[]): Promise<void> {
const showSize = extraArgs.includes('--size')
export async function listCommand(handlerUrl: string, dirPath: string, _extraArgs: string[]): Promise<void> {
await Disposable.use(getSyncedHandler({ url: handlerUrl }), async handler => {
const entries = await handler.list(dirPath, { prependDir: true })
const diskPaths = entries.filter(entry => isDisk(entry))
const diskPaths = entries.filter(entry => isDisk(handler, entry))
if (diskPaths.length === 0) {
console.log(`No disks found at ${dirPath}`)
@@ -96,14 +88,14 @@ export async function listCommand(handlerUrl: string, dirPath: string, extraArgs
const filename = diskPath.slice(diskPath.lastIndexOf('/') + 1)
try {
const disk = await Disposable.use(
openDisposableDisk({ handler, path: diskPath, ignoreBlockIndexes: !showSize }),
openDisposableDisk({ handler, path: diskPath }),
async (disk: RemoteDisk) => {
const differencing = disk.isDifferencing()
return {
ok: true as const,
filename,
uid: disk.getUuid(),
sizeOnDisk: showSize ? formatBytes(disk.getSizeOnDisk()) : undefined,
sizeOnDisk: formatBytes(disk.getSizeOnDisk()),
virtualSize: formatBytes(disk.getVirtualSize()),
differencing,
parentUid: differencing ? disk.getParentUuid() : null,
@@ -119,7 +111,7 @@ export async function listCommand(handlerUrl: string, dirPath: string, extraArgs
)
const sorted = sortDisks(disks)
const rows = sorted.map((disk, i) => toTableRow(disk, sorted[i - 1], showSize))
console.log(renderTable(buildHeaders(showSize), rows))
const rows = sorted.map((disk, i) => toTableRow(disk, sorted[i - 1]))
console.log(renderTable(HEADERS, rows))
})
}

View File

@@ -42,7 +42,6 @@ declare module '@xen-orchestra/backup-archive/disks/openDiskChain.mjs' {
handler: RemoteHandler
path: string
until?: string
ignoreBlockIndexes?: boolean
}): Promise<RandomAccessDisk>
}
@@ -53,10 +52,8 @@ declare module '@xen-orchestra/backup-archive/disks' {
export interface RemoteDisk extends RandomAccessDisk {
getSizeOnDisk(): number
getPath(): string
getPaths(): string[]
getUuid(): string
getParentUuid(): string
getParentPath(): string
getMaxBlockCount(): number
}
@@ -65,20 +62,13 @@ declare module '@xen-orchestra/backup-archive/disks' {
dispose: () => Promise<void>
}
export function isDisk(path: string): boolean
export function isDisk(handler: RemoteHandler, path: string): boolean
export function openDisposableDisk(params: {
handler: RemoteHandler
path: string
force?: boolean
ignoreBlockIndexes?: boolean
}): Promise<DiskDisposable>
export function openDisposableDisk(params: { handler: RemoteHandler; path: string }): Promise<DiskDisposable>
// Returns a RemoteVhdDiskChain, which is a RemoteDisk exposing getPaths() over the whole lineage.
export function openDiskChain(params: {
handler: RemoteHandler
path: string
until?: string
ignoreBlockIndexes?: boolean
}): Promise<RemoteDisk>
}): Promise<RandomAccessDisk>
}

View File

@@ -14,8 +14,8 @@ const COMMANDS: Record<string, CommandFn> = {
function showHelp(exitCode: number): never {
const commandDescriptions: Record<string, string> = {
info: 'Show disk info (virtual size, uid, parent uid, block size); --chain shows the full parent chain, --size adds the size on disk',
list: 'List all disks at a path and display their properties in a table; --size adds the size on disk',
info: 'Show disk info (virtual size, uid, parent uid, block size)',
list: 'List all disks at a path and display their properties in a table',
transform: 'Convert a disk to another format and write to stdout (raw | vhd | qcow2)',
}
@@ -30,9 +30,7 @@ ${commandList}
Examples:
xo-disk-cli info file:///mnt/backups /xo-vm-backups/uuid/disk.alias.vhd
xo-disk-cli info file:///mnt/backups /xo-vm-backups/uuid/disk.alias.vhd --chain --size
xo-disk-cli list file:///mnt/backups /xo-vm-backups/uuid/
xo-disk-cli list file:///mnt/backups /xo-vm-backups/uuid/ --size
xo-disk-cli transform file:///mnt/backups /xo-vm-backups/uuid/disk.vhd raw > disk.img
xo-disk-cli transform file:///mnt/backups /xo-vm-backups/uuid/disk.vhd qcow2 > disk.qcow2
`)

View File

@@ -4,30 +4,25 @@
* Focus:
* - Handler and disk are always disposed, even when disk methods throw.
* - Errors from broken disks are propagated (not swallowed).
* - By default disks are opened with ignoreBlockIndexes: true (cheap); --size
* opens with ignoreBlockIndexes: false and prints the size on disk.
* - --chain opens the whole lineage and prints each disk.
*
* Design: mock.module() is called once at the top level. The mocks read mutable
* closure variables at call-time so individual tests can inject scenarios simply
* by reassigning a variable before calling the command.
* Design: mock.module() is called once at the top level. The mock for openDisk
* reads `currentDisk` at call-time via closure, so individual tests can inject
* failure scenarios simply by reassigning the variable before calling the command.
*/
import { describe, test, mock, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
// ---------------------------------------------------------------------------
// Spies & mutable state — created once and reused; reset in beforeEach.
// Spies — created once and reused; reset in beforeEach.
// ---------------------------------------------------------------------------
const handlerDispose = mock.fn(() => Promise.resolve())
const diskDispose = mock.fn(() => Promise.resolve())
const chainClose = mock.fn(() => Promise.resolve())
const baseDisk = {
isDifferencing: () => false,
getVirtualSize: () => 1024 * 1024 * 1024,
getBlockSize: () => 2 * 1024 * 1024,
getSizeOnDisk: () => 128 * 1024 * 1024,
getUuid: () => 'aaaa-bbbb',
getParentUuid: () => 'pppp-qqqq',
}
@@ -35,20 +30,9 @@ const baseDisk = {
// Reassigned per-test to inject different disk behaviours.
let currentDisk: typeof baseDisk = { ...baseDisk }
// Paths returned by the chain (root → leaf); reassigned per-test.
let chainPaths: string[] = ['/vdis/root.vhd', '/vdis/leaf.vhd']
// Records the options each openDisposableDisk call received.
const openDiskParams: Array<{ path: string; ignoreBlockIndexes?: boolean }> = []
const openChainParams: Array<{ path: string; ignoreBlockIndexes?: boolean }> = []
const openDiskChainSpy = mock.fn((params: { path: string; ignoreBlockIndexes?: boolean }) => {
openChainParams.push(params)
return Promise.resolve({ getPaths: () => chainPaths, close: chainClose })
})
// ---------------------------------------------------------------------------
// Module mocks — registered once. Implementations read mutable state at call-time.
// Module mocks — registered once. openDisk reads `currentDisk` at call-time
// so tests only need to reassign the variable, not re-mock the module.
// ---------------------------------------------------------------------------
mock.module('@xen-orchestra/fs', {
@@ -59,31 +43,14 @@ mock.module('@xen-orchestra/fs', {
mock.module('@xen-orchestra/backup-archive/disks', {
namedExports: {
openDisposableDisk: (params: { path: string; ignoreBlockIndexes?: boolean }) => {
openDiskParams.push(params)
return Promise.resolve({ value: currentDisk, dispose: diskDispose })
},
openDiskChain: openDiskChainSpy,
openDisposableDisk: () => Promise.resolve({ value: currentDisk, dispose: diskDispose }),
},
})
// Import the module under test AFTER mocks are registered so it receives the
// mocked implementations.
// mocked implementations. The same infoCommand reference is reused for all tests.
const { infoCommand } = await import('../../commands/info.mjs')
// Capture console.log output without polluting test output.
async function captureLog(fn: () => Promise<void>): Promise<string> {
const lines: string[] = []
const orig = console.log
console.log = (...args: unknown[]) => lines.push(args.map(String).join(' '))
try {
await fn()
} finally {
console.log = orig
}
return lines.join('\n')
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -92,16 +59,11 @@ describe('info command', () => {
beforeEach(() => {
handlerDispose.mock.resetCalls()
diskDispose.mock.resetCalls()
chainClose.mock.resetCalls()
openDiskChainSpy.mock.resetCalls()
openDiskParams.length = 0
openChainParams.length = 0
currentDisk = { ...baseDisk }
chainPaths = ['/vdis/root.vhd', '/vdis/leaf.vhd']
})
test('disposes handler and disk on success', async () => {
await captureLog(() => infoCommand('file:///test', '/vdis/disk.vhd', []))
await infoCommand('file:///test', '/vdis/disk.vhd', [])
assert.strictEqual(handlerDispose.mock.callCount(), 1, 'handler must be disposed once')
assert.strictEqual(diskDispose.mock.callCount(), 1, 'disk must be disposed once')
})
@@ -127,63 +89,4 @@ describe('info command', () => {
}
await assert.rejects(() => infoCommand('file:///test', '/vdis/disk.vhd', []), /corrupt header/)
})
test('by default opens the disk with ignoreBlockIndexes: true and hides size on disk', async () => {
const output = await captureLog(() => infoCommand('file:///test', '/vdis/disk.vhd', []))
assert.strictEqual(openDiskParams.length, 1)
assert.strictEqual(openDiskParams[0].ignoreBlockIndexes, true, 'block indexes must be skipped by default')
assert.ok(!output.includes('Size on disk'), 'size on disk must not be printed without --size')
})
test('--size opens the disk with ignoreBlockIndexes: false and prints size on disk', async () => {
const output = await captureLog(() => infoCommand('file:///test', '/vdis/disk.vhd', ['--size']))
assert.strictEqual(openDiskParams[0].ignoreBlockIndexes, false, 'block indexes must be read with --size')
assert.ok(output.includes('Size on disk'), 'size on disk must be printed with --size')
})
test('without --chain, openDiskChain is never called', async () => {
await captureLog(() => infoCommand('file:///test', '/vdis/disk.vhd', []))
assert.strictEqual(openDiskChainSpy.mock.callCount(), 0, 'openDiskChain must not be called without --chain')
})
test('--chain: opens the chain, opens each disk in it, and disposes everything', async () => {
await captureLog(() => infoCommand('file:///test', '/vdis/leaf.vhd', ['--chain']))
assert.strictEqual(openDiskChainSpy.mock.callCount(), 1, 'openDiskChain must be called once')
assert.strictEqual(chainClose.mock.callCount(), 1, 'chain must be closed after enumerating paths')
// One openDisposableDisk (and thus one dispose) per disk in the chain.
assert.strictEqual(diskDispose.mock.callCount(), chainPaths.length, 'each chain disk must be opened and disposed')
assert.strictEqual(handlerDispose.mock.callCount(), 1, 'handler must be disposed once')
})
test('--chain defaults to ignoreBlockIndexes: true for both the chain and each disk', async () => {
await captureLog(() => infoCommand('file:///test', '/vdis/leaf.vhd', ['--chain']))
assert.strictEqual(openChainParams[0].ignoreBlockIndexes, true, 'chain walk must skip block indexes by default')
assert.ok(
openDiskParams.every(p => p.ignoreBlockIndexes === true),
'each chain disk must be opened with ignoreBlockIndexes: true'
)
})
test('--chain --size reads block indexes and prints size on disk for each disk', async () => {
const output = await captureLog(() => infoCommand('file:///test', '/vdis/leaf.vhd', ['--chain', '--size']))
assert.strictEqual(openChainParams[0].ignoreBlockIndexes, false, 'chain walk must read block indexes with --size')
assert.ok(
openDiskParams.every(p => p.ignoreBlockIndexes === false),
'each chain disk must be opened with ignoreBlockIndexes: false'
)
const sizeLines = output.split('\n').filter(l => l.includes('Size on disk'))
assert.strictEqual(sizeLines.length, chainPaths.length, 'size on disk must be printed for each disk')
})
test('--chain: chain is closed even when opening a disk in it throws', async () => {
currentDisk = {
...baseDisk,
getUuid: () => {
throw new Error('corrupt header')
},
}
await assert.rejects(() => infoCommand('file:///test', '/vdis/leaf.vhd', ['--chain']), /corrupt header/)
assert.strictEqual(chainClose.mock.callCount(), 1, 'chain must be closed even on error')
assert.strictEqual(handlerDispose.mock.callCount(), 1, 'handler must be disposed even on error')
})
})

View File

@@ -27,9 +27,6 @@ const baseDisk = {
getParentUuid: () => null as unknown as string,
}
// Records the options each openDisposableDisk call received.
const openDiskParams: Array<{ path: string; ignoreBlockIndexes?: boolean }> = []
// Reassigned per-test to control what handler.list() returns (or throws).
let currentListImpl: () => Promise<string[]> = async () => []
@@ -54,10 +51,9 @@ mock.module('@xen-orchestra/fs', {
mock.module('@xen-orchestra/backup-archive/disks', {
namedExports: {
isDisk: (p: string) => p.endsWith('.vhd'),
openDisposableDisk: (params: { path: string; ignoreBlockIndexes?: boolean }) => {
openDiskParams.push(params)
if (brokenPaths.has(params.path)) throw new Error('disk header corrupted')
isDisk: (_h: unknown, p: string) => p.endsWith('.vhd'),
openDisposableDisk: ({ path }: { path: string }) => {
if (brokenPaths.has(path)) throw new Error('disk header corrupted')
return Promise.resolve({ value: { ...baseDisk }, dispose: diskDispose })
},
},
@@ -90,7 +86,6 @@ describe('list command', () => {
handlerDispose.mock.resetCalls()
diskDispose.mock.resetCalls()
brokenPaths.clear()
openDiskParams.length = 0
currentListImpl = async () => ['/dir/disk.vhd']
})
@@ -127,22 +122,4 @@ describe('list command', () => {
const output = await captureLog(() => listCommand('file:///test', '/dir', []))
assert.ok(output.includes('No disks found'))
})
test('by default opens disks with ignoreBlockIndexes: true and hides the size column', async () => {
const output = await captureLog(() => listCommand('file:///test', '/dir', []))
assert.ok(
openDiskParams.every(p => p.ignoreBlockIndexes === true),
'disks must be opened with ignoreBlockIndexes: true by default'
)
assert.ok(!output.includes('Size on disk'), 'the size column must be hidden without --size')
})
test('--size opens disks with ignoreBlockIndexes: false and shows the size column', async () => {
const output = await captureLog(() => listCommand('file:///test', '/dir', ['--size']))
assert.ok(
openDiskParams.every(p => p.ignoreBlockIndexes === false),
'disks must be opened with ignoreBlockIndexes: false with --size'
)
assert.ok(output.includes('Size on disk'), 'the size column must appear with --size')
})
})

View File

@@ -2,10 +2,6 @@ import { describe, test } from 'node:test'
import assert from 'node:assert/strict'
import { sortDisks, toTableRow, type DiskInfo } from '../commands/list.mjs'
// These tests assert on the full-width row (with the "Size on disk" column), so
// they always exercise toTableRow with showSize = true.
const toTableRowWithSize = (disk: DiskInfo, prevDisk: DiskInfo | undefined) => toTableRow(disk, prevDisk, true)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -110,43 +106,43 @@ describe('sortDisks', () => {
describe('toTableRow', () => {
test('base disk shows "(none)" in parent uid column', () => {
const row = toTableRowWithSize(ok('a'), undefined)
const row = toTableRow(ok('a'), undefined)
assert.strictEqual(row[5], '(none)')
})
test('differencing disk with no previous disk shows parentUid', () => {
const row = toTableRowWithSize(ok('b', 'a'), undefined)
const row = toTableRow(ok('b', 'a'), undefined)
assert.strictEqual(row[5], 'a')
})
test('differencing disk whose parent is not the previous row shows parentUid', () => {
const row = toTableRowWithSize(ok('b', 'a'), ok('x'))
const row = toTableRow(ok('b', 'a'), ok('x'))
assert.strictEqual(row[5], 'a')
})
test('differencing disk whose parent is immediately above shows ↑', () => {
const row = toTableRowWithSize(ok('b', 'a'), ok('a'))
const row = toTableRow(ok('b', 'a'), ok('a'))
assert.strictEqual(row[5], '↑')
})
test('error disk shows error message in second column', () => {
const row = toTableRowWithSize(err('bad.vhd'), undefined)
const row = toTableRow(err('bad.vhd'), undefined)
assert.ok(row[1].includes('error'))
assert.ok(row[1].includes('disk header corrupted'))
})
test('differencing column shows "yes" for differencing disk', () => {
assert.strictEqual(toTableRowWithSize(ok('b', 'a'), undefined)[4], 'yes')
assert.strictEqual(toTableRow(ok('b', 'a'), undefined)[4], 'yes')
})
test('differencing column shows "no" for base disk', () => {
assert.strictEqual(toTableRowWithSize(ok('a'), undefined)[4], 'no')
assert.strictEqual(toTableRow(ok('a'), undefined)[4], 'no')
})
test('parent with multiple children: at least one child shows parent UID instead of ↑', () => {
const sorted = sortDisks([ok('c2', 'a'), ok('a'), ok('c1', 'a')])
const rows = sorted.map((disk, i) => toTableRowWithSize(disk, sorted[i - 1]))
const rows = sorted.map((disk, i) => toTableRow(disk, sorted[i - 1]))
// Find rows for the two children
const childRows = rows.filter((_row, i) => {

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/disk-transform",
"version": "1.3.3",
"version": "1.2.3",
"main": "./dist/index.mjs",
"license": "MIT",
"private": false,
@@ -10,7 +10,7 @@
"rimraf": "^6.0.1",
"@types/node": "^22.3.0",
"typescript": "~5.6",
"typescript-eslint": "^8.61.0"
"typescript-eslint": "^8.23.0"
},
"scripts": {
"build": "tsc",
@@ -34,6 +34,6 @@
"node": ">=20.17"
},
"dependencies": {
"@vates/generator-toolbox": "^1.1.3"
"@vates/generator-toolbox": "^1.1.2"
}
}

View File

@@ -1,4 +1,4 @@
import { ComposedProgressHandler, ProgressHandler } from './ProgressHandler.mjs'
import { ProgressHandler } from './ProgressHandler.mjs'
export type DiskBlockData = Buffer
export type DiskBlock = {
@@ -11,20 +11,13 @@ export type BytesLength = number
export abstract class Disk {
#generatedDiskBlocks = 0
#parent?: Disk
#progressHandler = new ComposedProgressHandler()
get progressHandler(): ProgressHandler {
return this.#progressHandler
}
progressHandler?: ProgressHandler
get parent(): Disk | undefined {
return this.#parent
}
abstract getVirtualSize(): number
abstract getBlockSize(): number
getBlockIndexesCount(): number {
return this.getBlockIndexes().length
}
abstract init(): Promise<void>
abstract close(): Promise<void>
@@ -73,9 +66,9 @@ export abstract class Disk {
return Promise.resolve(true)
}
addProgressHandler(progressHandler: ProgressHandler) {
this.#progressHandler.add(progressHandler)
}
// @todo readRawData will be needed for file level restore
// @todo rename will be needed for merge
// @todo : should the alias part moved from remote/vhd ?
}
/**

View File

@@ -42,12 +42,12 @@ test('Disk class', async t => {
await t.test('diskBlocks calls progressHandler.done when consumer exits early', async () => {
let doneCalled = false
disk.addProgressHandler({
disk.progressHandler = {
setProgress: async () => {},
done: async () => {
doneCalled = true
},
})
}
disk.blocks[4] = true
disk.blocks[5] = true
disk.blocks[6] = true

View File

@@ -31,15 +31,11 @@ export class DiskLargerBlock extends RandomAccessDisk {
throw new Error('Method not implemented.')
}
async readBlock(index: number): Promise<DiskBlock> {
if (!this.hasBlock(index)) {
throw new Error(`Block ${index} not present in this disk`)
}
// @todo handle partial block at the end
const source = this.source
const destinationBlockData = Buffer.alloc(this.getBlockSize(), 0)
const destinationBlock = Buffer.alloc(this.getBlockSize(), 0)
const blockRatio = this.#blockSize / source.getBlockSize()
const firstSourceBlockIndex = index * blockRatio
for (let i = firstSourceBlockIndex; i < firstSourceBlockIndex + blockRatio; i++) {
for (let i = index * blockRatio; i < (index + 1) * blockRatio; i++) {
let data: Buffer | undefined
if (source.hasBlock(i)) {
data = (await source.readBlock(i)).data
@@ -55,12 +51,12 @@ export class DiskLargerBlock extends RandomAccessDisk {
}
}
if (data !== undefined) {
data.copy(destinationBlockData, (i - firstSourceBlockIndex) * source.getBlockSize())
data.copy(destinationBlock, i * source.getBlockSize())
}
}
return {
index,
data: destinationBlockData,
data: destinationBlock,
}
}
@@ -70,26 +66,19 @@ export class DiskLargerBlock extends RandomAccessDisk {
getBlockIndexes(): Array<number> {
const maxBlock = Math.ceil(this.getVirtualSize() / this.getBlockSize())
const blockRatio = this.getBlockSize() / this.source.getBlockSize()
const indexes = []
for (let i = 0; i < maxBlock; i++) {
if (this.hasBlock(i)) {
indexes.push(i)
for (let j = 0; j < blockRatio; j++) {
if (this.source.hasBlock(i * blockRatio + j)) {
indexes.push(i)
break
}
}
}
return indexes
}
getBlockIndexesCount(): number {
let count = 0
const maxBlock = Math.ceil(this.getVirtualSize() / this.getBlockSize())
for (let i = 0; i < maxBlock; i++) {
if (this.hasBlock(i)) {
count++
}
}
return count
}
hasBlock(index: number): boolean {
const source = this.source
let maxBlockSource = Math.ceil(source.getVirtualSize() / source.getBlockSize())

View File

@@ -1,4 +1,4 @@
import assert, { strictEqual } from 'node:assert'
import assert from 'node:assert'
import { test } from 'node:test'
import { DiskLargerBlock } from './DiskLargerBlock.mjs'
import { RandomAccessDisk, DiskBlock, Disk } from './Disk.mjs'
@@ -45,7 +45,7 @@ class MockDisk extends RandomAccessDisk {
this.closed = true
}
isDifferencing(): boolean {
return this.parentDisk !== null || this.isDiff
return this.isDiff
}
instantiateParent(): RandomAccessDisk {
if (!this.parentDisk) throw new Error('No parent disk available')
@@ -118,29 +118,6 @@ test('readBlock with simple block mapping', async () => {
assert(result.data.subarray(512).equals(block2))
})
test('readBlock at index > 0 places source data at block-relative offsets', async () => {
// Regression test: a large block after index 0 must be assembled from its own
// source blocks at offsets relative to the start of that large block.
// With distinct, non-zero patterns this catches the case where copy() targets an
// absolute (disk-wide) offset and silently produces a zeroed block.
const blockC = createPatternBuffer(512, 'C')
const blockD = createPatternBuffer(512, 'D')
const source = new MockDisk(512, 4096, [
[2, blockC],
[3, blockD],
])
await source.init()
const disk = new DiskLargerBlock(source, 1024) // blockRatio = 2, so large block 1 = source blocks 2 & 3
await disk.init()
const result = await disk.readBlock(1)
assert.strictEqual(result.index, 1)
assert.strictEqual(result.data.length, 1024)
assert(result.data.subarray(0, 512).equals(blockC))
assert(result.data.subarray(512).equals(blockD))
})
test('hasBlock behavior', async () => {
const source = new MockDisk(512, 2048, [
[0, Buffer.alloc(512)],
@@ -237,34 +214,6 @@ test('close propagation', async () => {
await disk.close()
assert.strictEqual(source.closed, true)
})
test('getBlockIndexesCount', async () => {
const block = createPatternBuffer(512, 'BLOCK')
let source = new MockDisk(512, 4096, [
[2, block],
[3, block],
])
await source.init()
let disk = new DiskLargerBlock(source, 1024)
await disk.init()
assert.strictEqual(disk.getBlockIndexesCount(), 1)
assert.strictEqual(disk.getBlockIndexesCount(), disk.getBlockIndexes().length)
await disk.close()
source = new MockDisk(512, 4096, [
[0, block],
[3, block],
])
await source.init()
disk = new DiskLargerBlock(source, 1024)
await disk.init()
assert.strictEqual(disk.getBlockIndexesCount(), 2)
assert.strictEqual(disk.getBlockIndexesCount(), disk.getBlockIndexes().length)
await disk.close()
})
test('generator', async () => {
const source = new MockDisk(512, 1024)
@@ -276,29 +225,3 @@ test('generator', async () => {
assert.strictEqual(block.data.length, 1024)
}
})
test('readblock missing block throws', async () => {
const parentBlock = createPatternBuffer(512, 'PARENT')
const childBlock = createPatternBuffer(512, 'CHILD')
const sourceParent = new MockDisk(512, 2048, [
[0, parentBlock],
[1, parentBlock],
[2, parentBlock],
[3, parentBlock],
])
await sourceParent.init()
const source = new MockDisk(512, 2048, [[2, childBlock]])
source.parentDisk = sourceParent
await source.init()
const disk = new DiskLargerBlock(source, 1024)
await disk.init()
assert.strictEqual(disk.getBlockIndexesCount(), 1)
for await (const block of disk.diskBlocks()) {
assert.strictEqual(block.index, 1)
assert.ok(block.data.slice(0, 512).equals(childBlock), 'missing child data ')
assert.ok(block.data.slice(512, 1024).equals(parentBlock), 'missing parent data')
}
await assert.rejects(() => disk.readBlock(0))
})

View File

@@ -42,9 +42,6 @@ export class DiskPassthrough extends Disk {
getBlockIndexes(): Array<number> {
return this.source.getBlockIndexes()
}
getBlockIndexesCount(): number {
return this.source.getBlockIndexesCount()
}
hasBlock(index: number): boolean {
return this.source.hasBlock(index)
}
@@ -105,9 +102,6 @@ export abstract class RandomDiskPassthrough extends RandomAccessDisk {
getBlockIndexes(): Array<number> {
return this.source.getBlockIndexes()
}
getBlockIndexesCount(): number {
return this.source.getBlockIndexesCount()
}
hasBlock(index: number): boolean {
return this.source.hasBlock(index)
}

View File

@@ -72,22 +72,6 @@ export class DiskSmallerBlock extends DiskPassthrough {
#isBlockBeforeEnd(blockIndex: number): boolean {
return blockIndex < this.#maxBlockIndex
}
getBlockIndexesCount(): number {
let count = this.source.getBlockIndexesCount() * this.#blockRatio
// Only the last source block can exceed #maxBlockIndex.
// If it is allocated, subtract the sub-blocks which are upper than the virtual size.
const lastSourceBlockIndex = Math.ceil(this.getVirtualSize() / this.source.getBlockSize()) - 1
if (this.source.hasBlock(lastSourceBlockIndex)) {
const rest = (lastSourceBlockIndex + 1) * this.#blockRatio - this.#maxBlockIndex
if (rest > 0) {
count -= rest
}
}
return count
}
getBlockIndexes(): Array<number> {
const blockRatio = this.source.getBlockSize() / this.getBlockSize()
const sourceIndexes = this.source.getBlockIndexes()

View File

@@ -183,51 +183,6 @@ test('close propagation', async () => {
assert(source.closed)
})
test('getBlockIndexesCount - last source block not allocated, no trimming needed', async () => {
// virtualSize=2048, sourceBlockSize=512, blockSize=256, blockRatio=2
// #maxBlockIndex=8, lastSourceBlockIndex=3, hasBlock(3)=false
// count = 2 * 2 = 4
const source = new MockDisk(512, 2048, [
[0, Buffer.alloc(512)],
[2, Buffer.alloc(512)],
])
await source.init()
const disk = new DiskSmallerBlock(source, 256)
await disk.init()
assert.strictEqual(disk.getBlockIndexesCount(), 4)
assert.strictEqual(disk.getBlockIndexesCount(), disk.getBlockIndexes().length)
})
test('getBlockIndexesCount - last source block allocated and aligned, excess=0', async () => {
// virtualSize=1024, sourceBlockSize=512, blockSize=256, blockRatio=2
// #maxBlockIndex=4, lastSourceBlockIndex=1, hasBlock(1)=true
// excess = (1+1)*2 - 4 = 0 -> count = 1*2 = 2
const source = new MockDisk(512, 1024, [[1, Buffer.alloc(512)]])
await source.init()
const disk = new DiskSmallerBlock(source, 256)
await disk.init()
assert.strictEqual(disk.getBlockIndexesCount(), 2)
assert.strictEqual(disk.getBlockIndexesCount(), disk.getBlockIndexes().length)
})
test('getBlockIndexesCount - unaligned end, last source block allocated, excess>0', async () => {
// virtualSize=2304, sourceBlockSize=1024, blockSize=256, blockRatio=4
// #maxBlockIndex=9, lastSourceBlockIndex=2, hasBlock(2)=true
// excess = (2+1)*4 - 9 = 3 -> count = 2*4 - 3 = 5
const source = new MockDisk(1024, 2048 + 256, [
[0, Buffer.alloc(1024)],
[2, Buffer.alloc(1024)],
])
await source.init()
const disk = new DiskSmallerBlock(source, 256)
await disk.init()
assert.strictEqual(disk.getBlockIndexesCount(), 5)
assert.strictEqual(disk.getBlockIndexesCount(), disk.getBlockIndexes().length)
})
test('unaligned END ', async () => {
const source = new MockDisk(1024, 2048 + 256, [
[0, Buffer.alloc(1024)],

View File

@@ -49,9 +49,6 @@ export class NegativeDisk extends RandomAccessDisk {
getBlockIndexes(): Array<number> {
return this.#child.getBlockIndexes()
}
getBlockIndexesCount(): number {
return this.#child.getBlockIndexesCount()
}
hasBlock(index: number): boolean {
return this.#child.hasBlock(index)
}

View File

@@ -2,17 +2,3 @@ export interface ProgressHandler {
setProgress(total: number): void | Promise<void>
done(): void | Promise<void>
}
export class ComposedProgressHandler implements ProgressHandler {
#handlers: Array<ProgressHandler> = []
async setProgress(total: number) {
await Promise.all(this.#handlers.map(handler => handler.setProgress(total)))
}
async done() {
await Promise.all(this.#handlers.map(handler => handler.done()))
}
add(handler: ProgressHandler) {
this.#handlers.push(handler)
}
}

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "4.9.3",
"version": "4.8.0",
"license": "AGPL-3.0-or-later",
"description": "The File System for Xen Orchestra backups.",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
@@ -21,11 +21,11 @@
},
"dependencies": {
"@aws-sdk/abort-controller": "^3.272.0",
"@aws-sdk/client-s3": "^3.1065.0",
"@aws-sdk/lib-storage": "^3.1065.0",
"@aws-sdk/client-s3": "^3.54.0",
"@aws-sdk/lib-storage": "^3.54.0",
"@aws-sdk/middleware-apply-body-checksum": "^3.58.0",
"@aws-sdk/node-http-handler": "^3.54.0",
"@azure/storage-blob": "^12.32.0",
"@azure/storage-blob": "^12.26.0",
"@vates/async-each": "^1.0.3",
"@vates/coalesce-calls": "^0.1.0",
"@vates/decorate-with": "^2.1.0",
@@ -45,11 +45,11 @@
"xo-remote-parser": "^0.10.1"
},
"devDependencies": {
"@babel/cli": "^7.29.0",
"@babel/core": "^7.29.0",
"@babel/plugin-proposal-decorators": "^7.29.0",
"@babel/plugin-proposal-function-bind": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@babel/cli": "^7.0.0",
"@babel/core": "^7.0.0",
"@babel/plugin-proposal-decorators": "^7.1.6",
"@babel/plugin-proposal-function-bind": "^7.0.0",
"@babel/preset-env": "^7.8.0",
"cross-env": "^7.0.2",
"dotenv": "^16.0.0",
"rimraf": "^6.0.1",

View File

@@ -19,7 +19,6 @@ function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
algorithm: 'none',
key: 'none',
ivLength: 0,
authTagLength: 0,
encryptData: buffer => buffer,
encryptStream: stream => stream,
decryptData: buffer => buffer,
@@ -128,7 +127,6 @@ function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
id: algorithm,
algorithm,
key,
authTagLength,
ivLength,
encryptData,
encryptStream,

View File

@@ -292,14 +292,7 @@ export default class RemoteHandlerAbstract {
async outputStream(path, input, { checksum = true, dirMode, maxStreamLength, streamLength, validator } = {}) {
path = normalizePath(path)
let checksumStream
info('will output stream ', { path, streamLength, maxStreamLength, isEncrypted: this.isEncrypted })
if (this.isEncrypted) {
info('stream is encrypted', { path, streamLength, maxStreamLength })
const overhead = this.#encryptor.ivLength + this.#encryptor.authTagLength
if (streamLength !== undefined) streamLength += overhead
if (maxStreamLength !== undefined) maxStreamLength += overhead
info('stream is updated ', { path, streamLength, maxStreamLength, overhead })
}
input = this.#encryptor.encryptStream(input)
if (checksum) {
checksumStream = createChecksumStream()

View File

@@ -40,8 +40,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
#bucket
#dir
#s3
#httpAgent
#httpsAgent
#immutable = false
#maxPartSize
#maxPartNumber
@@ -79,13 +77,13 @@ export default class S3Handler extends RemoteHandlerAbstract {
region,
requestHandler: new NodeHttpHandler({
socketTimeout: 600000,
httpAgent: (this.#httpAgent = new HttpAgent({
httpAgent: new HttpAgent({
keepAlive: true,
})),
httpsAgent: (this.#httpsAgent = new HttpsAgent({
}),
httpsAgent: new HttpsAgent({
rejectUnauthorized: !allowUnauthorized,
keepAlive: true,
})),
}),
}),
// from https://github.com/aws/aws-sdk-js-v3/issues/6810
// some non AWS services like backblaze or cloudflare don't expect the new headers
@@ -102,12 +100,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
return 's3'
}
async _forget() {
this.#s3.destroy()
this.#httpAgent.destroy()
this.#httpsAgent.destroy()
}
#makeCopySource(path) {
return join(this.#bucket, this.#dir, path)
}
@@ -441,11 +433,7 @@ export default class S3Handler extends RemoteHandlerAbstract {
new DeleteObjectsCommand({
Bucket: this.#bucket,
Delete: {
// only keep the key: `ETag` and `Size` are also part of `ObjectIdentifier`, where they
// mean "delete only if it still matches", and passing the whole listed object would
// serialize them in the request. Beyond making the deletion conditional, some
// providers reject these elements with a `MalformedXML` error.
Objects: (result.Contents ?? []).map(({ Key }) => ({ Key })),
Objects: result.Contents ?? [],
},
})
)

View File

@@ -157,7 +157,7 @@ export abstract class RemoteHandlerAbstract {
abstract getInfo(): Promise<RemoteInfoResult>
abstract getSize(file: FileDescriptor): Promise<number>
abstract getSizeOnDisk(file: FileDescriptor): Promise<number>
abstract getSizeOnDisk(file: FileArg): Promise<number>
abstract lock(path: string): Promise<LockDisposer>
abstract test(): Promise<TestResult>
abstract isImmutable(): boolean

Some files were not shown because too many files have changed in this diff Show More