mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-12 10:12:58 -06:00
Compare commits
1 Commits
debug_time
...
pbt_aggreg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88495d763d |
@@ -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
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,18 +2,8 @@
|
||||
|
||||
# @vates/http-server-plus
|
||||
|
||||
[](https://npmjs.org/package/@vates/http-server-plus)  [](https://bundlephobia.com/result?p=@vates/http-server-plus) [](https://npmjs.org/package/@vates/http-server-plus)
|
||||
|
||||
> Augmented `http.Server`, 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)
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'] })
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,5 @@ export default {
|
||||
read: true,
|
||||
update: {
|
||||
tags: true,
|
||||
other_config: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export default {
|
||||
read: true,
|
||||
plug: true,
|
||||
unplug: true,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export default {
|
||||
'add-host': true,
|
||||
create: {
|
||||
network: true,
|
||||
vm: true,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export default {
|
||||
connect: true,
|
||||
create: true,
|
||||
delete: true,
|
||||
disconnect: true,
|
||||
read: true,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
export default {
|
||||
delete: true,
|
||||
export: true,
|
||||
read: true,
|
||||
update: {
|
||||
tags: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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] })
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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' }])
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 │
|
||||
├──────────────┼──────────────────────────────────────┼─────────────┼──────────────┼─────────────┼──────────────────────────────────────┤
|
||||
|
||||
@@ -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 │
|
||||
├──────────────┼──────────────────────────────────────┼─────────────┼──────────────┼─────────────┼──────────────────────────────────────┤
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
16
@xen-orchestra/disk-cli/src/declarations.d.ts
vendored
16
@xen-orchestra/disk-cli/src/declarations.d.ts
vendored
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
`)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ?? [],
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user