mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-13 18:51:03 -06:00
Compare commits
1 Commits
fix-docs-b
...
pbt_aggreg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88495d763d |
@@ -3,4 +3,4 @@
|
||||
**/*.hbs
|
||||
|
||||
# Generated files
|
||||
/@xen-orchestra/web/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))
|
||||
)
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"main": "./index.mjs",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/fs": "^4.9.0",
|
||||
"@xen-orchestra/fs": "^4.8.0",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"fuse-native": "^2.2.6",
|
||||
"lru-cache": "^7.14.0",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@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.0",
|
||||
"@xen-orchestra/disk-transform": "^1.2.3",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"xen-api": "^4.7.7"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@vates/types",
|
||||
"private": false,
|
||||
"version": "1.26.0",
|
||||
"version": "1.24.0",
|
||||
"main": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { XoHost, XoNetwork, XoVif } from './xo.mjs'
|
||||
import type { XoHost } from './xo.mjs'
|
||||
|
||||
declare const __brand: unique symbol
|
||||
|
||||
@@ -740,30 +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 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'] })
|
||||
|
||||
@@ -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,54 +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>
|
||||
}
|
||||
|
||||
export type XcpPatches = {
|
||||
changelog?: {
|
||||
author: string
|
||||
@@ -119,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>
|
||||
|
||||
@@ -182,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>
|
||||
@@ -230,11 +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>
|
||||
startVm(
|
||||
id: XoVm['id'],
|
||||
opts?: {
|
||||
@@ -252,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,26 +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'
|
||||
| '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
|
||||
@@ -133,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: {
|
||||
@@ -198,7 +184,7 @@ export type XoApp = {
|
||||
opts?: { bypassOtp?: boolean; bypassTaskCreation?: boolean }
|
||||
) => Promise<{ bypassOtp: boolean; expiration: number; user: XoUser }>
|
||||
/* 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)
|
||||
@@ -222,12 +208,6 @@ 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']): Promise<boolean>
|
||||
deleteAclV2Privilege(privilegeId: XoAclBasePrivilege['id'], options?: { force?: boolean }): Promise<boolean>
|
||||
@@ -318,8 +298,6 @@ export type XoApp = {
|
||||
): Promise<XoServer>
|
||||
rollingPoolReboot(pool: XoPool, opts?: { parentTask?: VatesTask }): Promise<void>
|
||||
rollingPoolUpdate(pool: XoPool, opts?: { rebootVm?: boolean; parentTask?: VatesTask }): Promise<void>
|
||||
setVmResourceSet(vmId: XoVm['id'], resourceSetId: string | null, force?: boolean): Promise<void>
|
||||
shareVmResourceSet(vmId: XoVm['id']): 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>
|
||||
@@ -359,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>
|
||||
|
||||
@@ -433,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']
|
||||
@@ -864,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.2.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@vates/types": "^1.26.0",
|
||||
"@vates/types": "^1.24.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "~5.6",
|
||||
"typescript-eslint": "^8.23.0"
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
export default {
|
||||
create: true,
|
||||
read: true,
|
||||
update: {
|
||||
enabled: true,
|
||||
name: true,
|
||||
options: true,
|
||||
proxy: true,
|
||||
url: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
export default {
|
||||
'allow-vm': true,
|
||||
disable: true,
|
||||
'migrate-receive': true,
|
||||
enable: true,
|
||||
evacuate: true,
|
||||
export: {
|
||||
logs: true,
|
||||
},
|
||||
'join-pool': true,
|
||||
read: true,
|
||||
update: {
|
||||
tags: 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,14 +1,10 @@
|
||||
export default {
|
||||
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,7 +4,6 @@ export default {
|
||||
delete: true,
|
||||
'export-content': true,
|
||||
'import-content': true,
|
||||
'migrate-send': true,
|
||||
read: true,
|
||||
update: {
|
||||
tags: true,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
export default {
|
||||
connect: true,
|
||||
create: true,
|
||||
delete: true,
|
||||
disconnect: true,
|
||||
read: 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.0.1",
|
||||
"version": "1.0.0",
|
||||
"description": "Helper to handle generic backup archives",
|
||||
"license": "ISC",
|
||||
"author": "",
|
||||
@@ -21,12 +21,12 @@
|
||||
"tar-stream": "^3.1.8",
|
||||
"@types/tar-stream": "^3.1.4",
|
||||
"tmp": "^0.2.1",
|
||||
"@vates/types": "^1.26.0"
|
||||
"@vates/types": "^1.21.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"@xen-orchestra/fs": "^4.9.0",
|
||||
"@xen-orchestra/disk-transform": "^1.3.0",
|
||||
"@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",
|
||||
@@ -41,7 +41,7 @@
|
||||
"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": {
|
||||
|
||||
@@ -69,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)) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -125,11 +125,7 @@ export class VmFullBackupArchive implements VmBackupInterface {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -686,31 +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')
|
||||
})
|
||||
|
||||
@@ -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] })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@xen-orchestra/async-map": "^0.1.3",
|
||||
"@xen-orchestra/backups": "^0.73.2",
|
||||
"@xen-orchestra/fs": "^4.9.0",
|
||||
"@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')
|
||||
@@ -643,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,94 +0,0 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import test from 'node:test'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
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])
|
||||
})
|
||||
}
|
||||
|
||||
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,476 +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 { 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.
|
||||
outputStream = tar.c({ cwd: path, gzip: true, jobs: 1 }, paths.map(makeRelative))
|
||||
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' }])
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,14 +124,12 @@ 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],
|
||||
@@ -153,7 +150,8 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
|
||||
|
||||
await asyncMapSettled(vmIds, _handleVm)
|
||||
}
|
||||
Task.set('progress', 100)
|
||||
|
||||
this._throwVmErrors(vmErrors)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
|
||||
const queue = new Set(vmIds)
|
||||
const taskByVmId = {}
|
||||
const nTriesByVmId = {}
|
||||
const vmErrors = []
|
||||
|
||||
const handleVm = vmUuid => {
|
||||
const getVmTask = () => {
|
||||
@@ -175,11 +176,19 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
|
||||
} else {
|
||||
// ending the task with error or not ending the task
|
||||
vmBackupFailed(taskError, task)
|
||||
if (isLastRun) {
|
||||
vmErrors.push(taskError)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(noop) // errors are handled by logs
|
||||
}),
|
||||
error => vmBackupFailed(error, getVmTask())
|
||||
error => {
|
||||
vmBackupFailed(error, getVmTask())
|
||||
if (isLastRun) {
|
||||
vmErrors.push(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
const { concurrency } = settings
|
||||
@@ -191,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -120,10 +116,7 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
|
||||
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')
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -229,14 +229,13 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
|
||||
Object.entries(deltaExport.disks),
|
||||
async ([diskRef, disk]) => {
|
||||
const path = `${this._vmBackupDir}/${vhds[diskRef]}`
|
||||
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,
|
||||
})
|
||||
size += transferred
|
||||
},
|
||||
{
|
||||
concurrency: settings.diskPerVmConcurrency,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.73.2",
|
||||
"version": "0.72.1",
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
},
|
||||
@@ -30,14 +30,14 @@
|
||||
"@vates/nbd-client": "^3.4.0",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@vates/task": "^0.7.0",
|
||||
"@vates/types": "^1.26.0",
|
||||
"@vates/types": "^1.24.0",
|
||||
"@xen-orchestra/async-map": "^0.1.3",
|
||||
"@xen-orchestra/disk-transform": "^1.3.0",
|
||||
"@xen-orchestra/fs": "^4.9.0",
|
||||
"@xen-orchestra/disk-transform": "^1.2.3",
|
||||
"@xen-orchestra/fs": "^4.8.0",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"@xen-orchestra/qcow2": "^1.3.0",
|
||||
"@xen-orchestra/template": "^0.1.1",
|
||||
"@xen-orchestra/backup-archive": "^1.0.1",
|
||||
"@xen-orchestra/backup-archive": "^1.0.0",
|
||||
"app-conf": "^3.0.0",
|
||||
"compare-versions": "^6.0.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
@@ -67,7 +67,7 @@
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xen-orchestra/xapi": "^8.8.0"
|
||||
"@xen-orchestra/xapi": "^8.7.2"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/disk-cli",
|
||||
"version": "2.1.1",
|
||||
"version": "2.0.0",
|
||||
"description": "CLI tool to inspect and manage disks in backup repositories",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -20,10 +20,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@xen-orchestra/backups": "^0.73.2",
|
||||
"@xen-orchestra/backup-archive": "^1.0.1",
|
||||
"@xen-orchestra/disk-transform": "^1.3.0",
|
||||
"@xen-orchestra/fs": "^4.9.0",
|
||||
"@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.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/disk-transform",
|
||||
"version": "1.3.0",
|
||||
"version": "1.2.3",
|
||||
"main": "./dist/index.mjs",
|
||||
"license": "MIT",
|
||||
"private": false,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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.0",
|
||||
"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",
|
||||
|
||||
@@ -1,134 +1,10 @@
|
||||
# Immutability
|
||||
### make a remote immutable
|
||||
|
||||
The goal is to make a remote that XO can write to, but not modify during the immutability duration set per remote. That way, it is not possible for XO to delete or encrypt any backup during this period. It protects your backups against ransomware, at least as long as the attacker does not have root access to the remote server.
|
||||
launch the `xo-immutable-remote` command. The configuration is stored in the config file.
|
||||
This script must be kept running to make file immutable reliably.
|
||||
|
||||
We target `governance` type of immutability: **the local root account of the remote server will be able to lift immutability**.
|
||||
### make file mutable
|
||||
|
||||
We use Linux filesystem capabilities (`chattr +i`). They are tested on process start.
|
||||
launch the `xo-lift-remote-immutability` cli. The configuration is stored in the config file .
|
||||
|
||||
It is compatible with encryption at rest made by XO.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- The service must run as root, or as a user with the `CAP_LINUX_IMMUTABLE` capability.
|
||||
- `chattr` and `lsattr` must be installed on the system.
|
||||
- The underlying filesystem must support the immutable attribute (ext2/ext3/ext4, btrfs, etc.).
|
||||
|
||||
## Configuring
|
||||
|
||||
This package uses `app-conf` to load its configuration. The application name is `xo-immutable-backups`. A sample config file is provided in this package.
|
||||
|
||||
The default config path is `/etc/xo-immutable-backups/config.toml`. Example:
|
||||
|
||||
```toml
|
||||
liftEvery = "1h"
|
||||
|
||||
[remotes.remote1]
|
||||
root = "/mnt/ssd/vhdblock/"
|
||||
immutabilityDuration = "7d"
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
- **`liftEvery`** (required): How often the service checks for files whose immutability duration has expired and lifts their protection (e.g. `1h`).
|
||||
- Per remote:
|
||||
- **`root`** (required): Absolute path to the root of the backup repository.
|
||||
- **`immutabilityDuration`** (required, minimum `1d`): How long files remain protected after the backup datetime encoded in their filename.
|
||||
- **`delayBetweenSizeCheck`** (optional, default `100ms`): Polling interval used when waiting for a file write to complete before locking it.
|
||||
|
||||
## CLI commands
|
||||
|
||||
- **`xo-immutable-remote`**: Start the watching daemon. Must be kept running to protect new backups reliably.
|
||||
- **`xo-lift-remote-immutability`**: Manually trigger a lifting pass. If `liftEvery` is set in the config, the process continues running and repeats the check on that interval.
|
||||
|
||||
## How protection works
|
||||
|
||||
On startup, `xo-immutable-remote`:
|
||||
|
||||
1. Verifies the filesystem supports immutability by creating, locking, and deleting a temporary test file.
|
||||
2. Writes an `immutability.json` file to the remote root (containing `since`, `duration`, and `immutable: true`) and locks it with `chattr +i`.
|
||||
3. Sets up `fs.watch` listeners on the backup directory tree (see below).
|
||||
|
||||
Protection is applied to a backup only after the file write is complete. The service polls the file size until it stabilises before locking. This means "real-time" here means "as soon as possible after each backup file is fully written".
|
||||
|
||||
### VM backups (`xo-vm-backups/<vmUUID>/`)
|
||||
|
||||
The watcher triggers on `<YYYYMMDD>T<HHmmss>[Z].json` files appearing in a VM directory. The `.json` metadata file is always written last by XO, so its stable size guarantees all other files for that run are already fully written.
|
||||
|
||||
Once stable, the following paths are locked together with a single `chattr +i -R` call:
|
||||
|
||||
```
|
||||
<vmDir>/<datetime>.json
|
||||
<vmDir>/<datetime>.xva
|
||||
<vmDir>/<datetime>.xva.checksum
|
||||
<vmDir>/vdis/<jobId>/<vdiId>/<datetime>.vhd (flat VHD or alias)
|
||||
<vmDir>/vdis/<jobId>/<vdiId>/<datetime>.alias.vhd
|
||||
<vmDir>/vdis/<jobId>/<vdiId>/data/<datetime>.vhd (VHD directory — locked recursively)
|
||||
```
|
||||
|
||||
Missing optional paths (e.g. `.xva` for delta backups, `.alias.vhd` for full backups) are silently skipped by `chattr`.
|
||||
|
||||
### Config backups (`xo-config-backups/<scheduleId>/<datetime>/`)
|
||||
|
||||
### Pool metadata backups (`xo-pool-metadata-backups/<scheduleId>/<poolUUID>/<datetime>/`)
|
||||
|
||||
The watcher triggers on `metadata.json` appearing in the date directory. Once its write is complete, the **entire date directory** is locked recursively with `chattr +i -R`, covering `data`, `data.json`, `metadata.json`, and any other files inside.
|
||||
|
||||
## How lifting works
|
||||
|
||||
On each `liftEvery` tick (and once immediately on startup), the service walks the backup directory tree directly:
|
||||
|
||||
- It parses the datetime encoded in each backup's filename or directory name.
|
||||
- It compares that datetime against the threshold `now - immutabilityDuration`.
|
||||
- Files older than the threshold have their immutability lifted with `chattr -i -R`.
|
||||
|
||||
The expiry reference is the **datetime in the filename**, not the file's `mtime`. XO periodically rewrites metadata `.json` files (cache refresh, reconciliation), which would otherwise reset `mtime` and defer expiry indefinitely.
|
||||
|
||||
On the **first lift run after startup**, all backup files are scanned unconditionally (full scan). This catches orphaned immutable files left by a previous partial or interrupted lock. Subsequent runs use a fast-path: only backups whose `.json` sentinel is currently immutable are processed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Some files are still immutable after the duration expired
|
||||
|
||||
Restart `xo-immutable-remote`. The first lift run after startup always performs a full scan, which will find and release any expired files that were missed.
|
||||
|
||||
### Make a remote fully mutable again
|
||||
|
||||
1. Stop the `xo-immutable-remote` service.
|
||||
2. As root on the file server, run:
|
||||
```bash
|
||||
chattr -i -R /path/to/remote/on/fileserver/
|
||||
```
|
||||
|
||||
### Increasing the immutability duration
|
||||
|
||||
Change the setting. The next lift cycle will use the new duration; files already past the old duration but within the new one will remain protected.
|
||||
|
||||
### Reducing the immutability duration
|
||||
|
||||
Change the setting, then run `xo-lift-remote-immutability` to apply immediately, or wait for the next scheduled `liftEvery` cycle.
|
||||
|
||||
### Why are my incremental backups not marked as protected in XO?
|
||||
|
||||
For incremental backups to be marked as protected in XO, the entire backup chain must be within the immutability window. To guarantee at least 7 days of protected backups, set both the immutability duration and the retention to 14 days, with a full backup interval of 7 days.
|
||||
|
||||
This ensures that when the last chain is complete, it and the previous chain are both fully under protection:
|
||||
|
||||
```
|
||||
Kd Kdddddd Kdddddd K # 8 backups protected, 2 chains
|
||||
K Kdddddd Kdddddd Kd # 9 backups protected, 2 chains
|
||||
Kdddddd Kdddddd Kdd # 10 backups protected, 2 chains
|
||||
Kddddd Kdddddd Kddd # 11 backups protected, 2 chains
|
||||
Kdddd Kdddddd Kdddd # 12 backups protected, 2 chains
|
||||
Kddd Kdddddd Kddddd # 13 backups protected, 2 chains
|
||||
Kdd Kdddddd Kdddddd # 7 backups protected, 1 chain (previous full is now mutable)
|
||||
Kd Kdddddd Kdddddd K # 8 backups protected, 2 chains
|
||||
```
|
||||
|
||||
(`K` = full backup, `d` = delta)
|
||||
|
||||
### Why doesn't the service start?
|
||||
|
||||
- It must run as root or as a user with the `CAP_LINUX_IMMUTABLE` capability.
|
||||
- The underlying filesystem must support the immutable attribute (`chattr`/`lsattr` must work).
|
||||
- Check logs with `journalctl`.
|
||||
If the config file have a `liftEvery`, this script will continue to run and check regularly if there are files to update.
|
||||
|
||||
@@ -14,140 +14,16 @@ npm install --save @xen-orchestra/immutable-backups
|
||||
|
||||
## Usage
|
||||
|
||||
# Immutability
|
||||
### make a remote immutable
|
||||
|
||||
The goal is to make a remote that XO can write to, but not modify during the immutability duration set per remote. That way, it is not possible for XO to delete or encrypt any backup during this period. It protects your backups against ransomware, at least as long as the attacker does not have root access to the remote server.
|
||||
launch the `xo-immutable-remote` command. The configuration is stored in the config file.
|
||||
This script must be kept running to make file immutable reliably.
|
||||
|
||||
We target `governance` type of immutability: **the local root account of the remote server will be able to lift immutability**.
|
||||
### make file mutable
|
||||
|
||||
We use Linux filesystem capabilities (`chattr +i`). They are tested on process start.
|
||||
launch the `xo-lift-remote-immutability` cli. The configuration is stored in the config file .
|
||||
|
||||
It is compatible with encryption at rest made by XO.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- The service must run as root, or as a user with the `CAP_LINUX_IMMUTABLE` capability.
|
||||
- `chattr` and `lsattr` must be installed on the system.
|
||||
- The underlying filesystem must support the immutable attribute (ext2/ext3/ext4, btrfs, etc.).
|
||||
|
||||
## Configuring
|
||||
|
||||
This package uses `app-conf` to load its configuration. The application name is `xo-immutable-backups`. A sample config file is provided in this package.
|
||||
|
||||
The default config path is `/etc/xo-immutable-backups/config.toml`. Example:
|
||||
|
||||
```toml
|
||||
liftEvery = "1h"
|
||||
|
||||
[remotes.remote1]
|
||||
root = "/mnt/ssd/vhdblock/"
|
||||
immutabilityDuration = "7d"
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
- **`liftEvery`** (required): How often the service checks for files whose immutability duration has expired and lifts their protection (e.g. `1h`).
|
||||
- Per remote:
|
||||
- **`root`** (required): Absolute path to the root of the backup repository.
|
||||
- **`immutabilityDuration`** (required, minimum `1d`): How long files remain protected after the backup datetime encoded in their filename.
|
||||
- **`delayBetweenSizeCheck`** (optional, default `100ms`): Polling interval used when waiting for a file write to complete before locking it.
|
||||
|
||||
## CLI commands
|
||||
|
||||
- **`xo-immutable-remote`**: Start the watching daemon. Must be kept running to protect new backups reliably.
|
||||
- **`xo-lift-remote-immutability`**: Manually trigger a lifting pass. If `liftEvery` is set in the config, the process continues running and repeats the check on that interval.
|
||||
|
||||
## How protection works
|
||||
|
||||
On startup, `xo-immutable-remote`:
|
||||
|
||||
1. Verifies the filesystem supports immutability by creating, locking, and deleting a temporary test file.
|
||||
2. Writes an `immutability.json` file to the remote root (containing `since`, `duration`, and `immutable: true`) and locks it with `chattr +i`.
|
||||
3. Sets up `fs.watch` listeners on the backup directory tree (see below).
|
||||
|
||||
Protection is applied to a backup only after the file write is complete. The service polls the file size until it stabilises before locking. This means "real-time" here means "as soon as possible after each backup file is fully written".
|
||||
|
||||
### VM backups (`xo-vm-backups/<vmUUID>/`)
|
||||
|
||||
The watcher triggers on `<YYYYMMDD>T<HHmmss>[Z].json` files appearing in a VM directory. The `.json` metadata file is always written last by XO, so its stable size guarantees all other files for that run are already fully written.
|
||||
|
||||
Once stable, the following paths are locked together with a single `chattr +i -R` call:
|
||||
|
||||
```
|
||||
<vmDir>/<datetime>.json
|
||||
<vmDir>/<datetime>.xva
|
||||
<vmDir>/<datetime>.xva.checksum
|
||||
<vmDir>/vdis/<jobId>/<vdiId>/<datetime>.vhd (flat VHD or alias)
|
||||
<vmDir>/vdis/<jobId>/<vdiId>/<datetime>.alias.vhd
|
||||
<vmDir>/vdis/<jobId>/<vdiId>/data/<datetime>.vhd (VHD directory — locked recursively)
|
||||
```
|
||||
|
||||
Missing optional paths (e.g. `.xva` for delta backups, `.alias.vhd` for full backups) are silently skipped by `chattr`.
|
||||
|
||||
### Config backups (`xo-config-backups/<scheduleId>/<datetime>/`)
|
||||
|
||||
### Pool metadata backups (`xo-pool-metadata-backups/<scheduleId>/<poolUUID>/<datetime>/`)
|
||||
|
||||
The watcher triggers on `metadata.json` appearing in the date directory. Once its write is complete, the **entire date directory** is locked recursively with `chattr +i -R`, covering `data`, `data.json`, `metadata.json`, and any other files inside.
|
||||
|
||||
## How lifting works
|
||||
|
||||
On each `liftEvery` tick (and once immediately on startup), the service walks the backup directory tree directly:
|
||||
|
||||
- It parses the datetime encoded in each backup's filename or directory name.
|
||||
- It compares that datetime against the threshold `now - immutabilityDuration`.
|
||||
- Files older than the threshold have their immutability lifted with `chattr -i -R`.
|
||||
|
||||
The expiry reference is the **datetime in the filename**, not the file's `mtime`. XO periodically rewrites metadata `.json` files (cache refresh, reconciliation), which would otherwise reset `mtime` and defer expiry indefinitely.
|
||||
|
||||
On the **first lift run after startup**, all backup files are scanned unconditionally (full scan). This catches orphaned immutable files left by a previous partial or interrupted lock. Subsequent runs use a fast-path: only backups whose `.json` sentinel is currently immutable are processed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Some files are still immutable after the duration expired
|
||||
|
||||
Restart `xo-immutable-remote`. The first lift run after startup always performs a full scan, which will find and release any expired files that were missed.
|
||||
|
||||
### Make a remote fully mutable again
|
||||
|
||||
1. Stop the `xo-immutable-remote` service.
|
||||
2. As root on the file server, run:
|
||||
```bash
|
||||
chattr -i -R /path/to/remote/on/fileserver/
|
||||
```
|
||||
|
||||
### Increasing the immutability duration
|
||||
|
||||
Change the setting. The next lift cycle will use the new duration; files already past the old duration but within the new one will remain protected.
|
||||
|
||||
### Reducing the immutability duration
|
||||
|
||||
Change the setting, then run `xo-lift-remote-immutability` to apply immediately, or wait for the next scheduled `liftEvery` cycle.
|
||||
|
||||
### Why are my incremental backups not marked as protected in XO?
|
||||
|
||||
For incremental backups to be marked as protected in XO, the entire backup chain must be within the immutability window. To guarantee at least 7 days of protected backups, set both the immutability duration and the retention to 14 days, with a full backup interval of 7 days.
|
||||
|
||||
This ensures that when the last chain is complete, it and the previous chain are both fully under protection:
|
||||
|
||||
```
|
||||
Kd Kdddddd Kdddddd K # 8 backups protected, 2 chains
|
||||
K Kdddddd Kdddddd Kd # 9 backups protected, 2 chains
|
||||
Kdddddd Kdddddd Kdd # 10 backups protected, 2 chains
|
||||
Kddddd Kdddddd Kddd # 11 backups protected, 2 chains
|
||||
Kdddd Kdddddd Kdddd # 12 backups protected, 2 chains
|
||||
Kddd Kdddddd Kddddd # 13 backups protected, 2 chains
|
||||
Kdd Kdddddd Kdddddd # 7 backups protected, 1 chain (previous full is now mutable)
|
||||
Kd Kdddddd Kdddddd K # 8 backups protected, 2 chains
|
||||
```
|
||||
|
||||
(`K` = full backup, `d` = delta)
|
||||
|
||||
### Why doesn't the service start?
|
||||
|
||||
- It must run as root or as a user with the `CAP_LINUX_IMMUTABLE` capability.
|
||||
- The underlying filesystem must support the immutable attribute (`chattr`/`lsattr` must work).
|
||||
- Check logs with `journalctl`.
|
||||
If the config file have a `liftEvery`, this script will continue to run and check regularly if there are files to update.
|
||||
|
||||
## Contributions
|
||||
|
||||
|
||||
114
@xen-orchestra/immutable-backups/doc.md
Normal file
114
@xen-orchestra/immutable-backups/doc.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Immutability
|
||||
|
||||
the goal is to make a remote that XO can write, but not modify during the immutability duration set on the remote. That way, it's not possible for XO to delete or encrypt any backup during this period. It protects your backup against ransomware, at least as long as the attacker does not have a root access to the remote server.
|
||||
|
||||
We target `governance` type of immutability, **the local root account of the remote server will be able to lift immutability**.
|
||||
|
||||
We use the file system capabilities, they are tested on the protection process start.
|
||||
|
||||
It is compatible with encryption at rest made by XO.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The commands must be run as root on the remote, or by a user with the `CAP_LINUX_IMMUTABLE` capability . On start, the protect process writes into the remote `immutability.json` file its status and the immutability duration.
|
||||
|
||||
the `chattr` and `lsattr` should be installed on the system
|
||||
|
||||
## Configuring
|
||||
|
||||
this package uses app-conf to store its config. The application name is `xo-immutable-backup`. A sample config file is provided in this package.
|
||||
|
||||
## Making a file immutable
|
||||
|
||||
when marking a file or a folder immutable, it create an alias file in the `<indexPath>/<DayOfFileCreation>/<sha256(fullpath)>`.
|
||||
|
||||
`indexPath` can be defined in the config file; otherwise, `XDG_HOME` is used. If not available it goes to `~/.local/share`
|
||||
|
||||
This index is used when lifting the immutability of the remote, it will only look at the old enough `<indexPath>/<DayOfFileCreation>/` folders.
|
||||
|
||||
## Real time protecting
|
||||
|
||||
On start, the watcher will create the index if it does not exists.
|
||||
It will also do a checkup to ensure immutability could work on this remote and handle the easiest issues.
|
||||
|
||||
The watching process depends on the backup type, since we don't want to make temporary files and cache immutable.
|
||||
|
||||
It won't protect files during upload, only when the files have been completely written on disk. Real time, in this case, means "protecting critical files as soon as possible after they are uploaded"
|
||||
|
||||
This can be alleviated by :
|
||||
|
||||
- Coupling immutability with encryption to ensure the file is not modified
|
||||
- Making health check to ensure the data are exactly as the snapshot data
|
||||
|
||||
List of protected files :
|
||||
|
||||
```js
|
||||
const PATHS = [
|
||||
// xo-config-backups/scheduleId/date/metadata.json
|
||||
'xo-config-backups/*/*/data',
|
||||
'xo-config-backups/*/*/data.json',
|
||||
'xo-config-backups/*/*/metadata.json',
|
||||
// xo-pool-metadata-backups/backupId/scheduleId/date/metadata.json
|
||||
'xo-pool-metadata-backups/*/*/*/metadata.json',
|
||||
'xo-pool-metadata-backups/*/*/*/data',
|
||||
// xo-vm-backups/<vmuuid>/
|
||||
'xo-vm-backups/*/*.json',
|
||||
'xo-vm-backups/*/*.xva',
|
||||
'xo-vm-backups/*/*.xva.checksum',
|
||||
// xo-vm-backups/<vmuuid>/vdis/<jobid>/<vdiUuid>
|
||||
'xo-vm-backups/*/vdis/*/*/*.vhd', // can be an alias or a vhd file
|
||||
// for vhd directory :
|
||||
'xo-vm-backups/*/vdis/*/*/data/*.vhd/bat',
|
||||
'xo-vm-backups/*/vdis/*/*/data/*.vhd/header',
|
||||
'xo-vm-backups/*/vdis/*/*/data/*.vhd/footer',
|
||||
]
|
||||
```
|
||||
|
||||
## Releasing protection on old enough files on a remote
|
||||
|
||||
the watcher will periodically check if some file must by unlocked
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### some files are still locked
|
||||
|
||||
add the `rebuildIndexOnStart` option to the config file
|
||||
|
||||
### make remote fully mutable again
|
||||
|
||||
- Update the immutability setting with a 0 duration
|
||||
- launch the `liftProtection` cli.
|
||||
- remove the `protectRemotes` service
|
||||
|
||||
### increasing the immutability duration
|
||||
|
||||
this will prolong immutable file, but won't protect files that are already out of immutability
|
||||
|
||||
### reducing the immutability duration
|
||||
|
||||
change the setting, and launch the `liftProtection` cli , or wait for next planed execution
|
||||
|
||||
### why are my incremental backups not marked as protected in XO ?
|
||||
|
||||
are not marked as protected in XO ?
|
||||
|
||||
For incremental backups to be marked as protected in XO, the entire chain must be under protection. To ensure at least 7 days of backups are protected, you need to set the immutability duration and retention at 14 days, the full backup interval at 7 days
|
||||
|
||||
That means that if the last backup chain is complete ( 7 backup ) it is completely under protection, and if not, the precedent chain is also under protection. K are key backups, and are delta
|
||||
|
||||
```
|
||||
Kd Kdddddd Kdddddd K # 8 backups protected, 2 chains
|
||||
K Kdddddd Kdddddd Kd # 9 backups protected, 2 chains
|
||||
Kdddddd Kdddddd Kdd # 10 backups protected, 2 chains
|
||||
Kddddd Kdddddd Kddd # 11 backups protected, 2 chains
|
||||
Kdddd Kdddddd Kdddd # 12 backups protected, 2 chains
|
||||
Kddd Kdddddd Kddddd # 13 backups protected, 2 chains
|
||||
Kdd Kdddddd Kdddddd # 7 backups protected, 1 chain since precedent full is now mutable
|
||||
Kd Kdddddd Kdddddd K # 8 backups protected, 2 chains
|
||||
```
|
||||
|
||||
### Why doesn't the protect process start ?
|
||||
|
||||
- it should be run as root or by a user with the `CAP_LINUX_IMMUTABLE` capability
|
||||
- the underlying file system should support immutability, especially the `chattr` and `lsattr` command
|
||||
- logs are in journalctl
|
||||
@@ -16,13 +16,13 @@
|
||||
"xo-immutable-remote": "./dist/protectRemotes.mjs"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "3.0.3",
|
||||
"version": "3.0.1",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@xen-orchestra/backups": "^0.73.2",
|
||||
"@xen-orchestra/backups": "^0.72.1",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"app-conf": "^3.0.0",
|
||||
"execa": "^5.0.0",
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
|
||||
## **next**
|
||||
|
||||
- Update the UiTitle component to use the one from web-core (PR [#9869](https://github.com/vatesfr/xen-orchestra/pull/9869))
|
||||
- [Pool/System] Add `Reboot VM on internal shutdown` in pool's system tab (PR [#9962](https://github.com/vatesfr/xen-orchestra/pull/9962))
|
||||
|
||||
## **0.22.0** (2026-05-28)
|
||||
|
||||
- [System] Update system pages layout ([user feedback](https://feedback.vates.tech/posts/37/xo6-ui-very-messy-columns-width-in-vm-system-far-better-in-xo5)) (PR [#9746](https://github.com/vatesfr/xen-orchestra/pull/9746))
|
||||
- Introduce robots.txt for avoid browser indexing (PR [#9858](https://github.com/vatesfr/xen-orchestra/pull/9858))
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/lite",
|
||||
"version": "0.22.0",
|
||||
"version": "0.21.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
|
||||
@@ -28,13 +28,13 @@
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^18.19.80",
|
||||
"@vates/types": "^1.26.0",
|
||||
"@vates/types": "^1.24.0",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"@vueuse/math": "^14.0.0",
|
||||
"@vueuse/shared": "^14.0.0",
|
||||
"@xen-orchestra/web-core": "^0.54.0",
|
||||
"@xen-orchestra/web-core": "^0.52.0",
|
||||
"complex-matcher": "^1.1.1",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
@@ -59,6 +59,7 @@
|
||||
"postcss-nested": "^7.0.2",
|
||||
"semver": "^7.3.2",
|
||||
"typescript": "^6.0.0",
|
||||
"unplugin-vue-router": "^0.12.0",
|
||||
"vite": "^8.0.0",
|
||||
"vue": "~3.5.13",
|
||||
"vue-i18n": "^11.1.2",
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<VtsModalList />
|
||||
<VtsDrawerList />
|
||||
<VtsTooltipList />
|
||||
</template>
|
||||
|
||||
@@ -23,13 +22,13 @@ import AppNavigation from '@/components/AppNavigation.vue'
|
||||
import { useUnreachableHosts } from '@/composables/unreachable-hosts.composable'
|
||||
import { usePoolStore } from '@/stores/xen-api/pool.store'
|
||||
import { useXenApiStore } from '@/stores/xen-api.store'
|
||||
import VtsDrawerList from '@core/components/drawer/VtsDrawerList.vue'
|
||||
import VtsModalList from '@core/components/modal/VtsModalList.vue'
|
||||
import VtsTooltipList from '@core/components/tooltip-list/VtsTooltipList.vue'
|
||||
import { useChartTheme } from '@core/composables/chart-theme.composable'
|
||||
import { useUiStore } from '@core/stores/ui.store'
|
||||
import { useActiveElement, useMagicKeys, whenever } from '@vueuse/core'
|
||||
import { logicAnd } from '@vueuse/math'
|
||||
import { getActivePinia } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
@@ -40,8 +39,13 @@ const xenApiStore = useXenApiStore()
|
||||
|
||||
const { pool } = usePoolStore().subscribe()
|
||||
|
||||
const uiStore = useUiStore()
|
||||
useChartTheme()
|
||||
// workaround
|
||||
// since this commit https://github.com/vatesfr/xen-orchestra/commit/ac2f4e9f32beee27ce4d14ad0d4ce7d9c51a1d82
|
||||
// useUiStore is unable to find the pinia instance itself.
|
||||
const pinia = getActivePinia()
|
||||
const uiStore = useUiStore(pinia)
|
||||
useChartTheme(pinia)
|
||||
// end workaround
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
const { locale } = useI18n()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<RouterLink :to="{ name: '/story/' }">
|
||||
<span class="typo-h4">Stories</span>
|
||||
<UiTitle type="h4">Stories</UiTitle>
|
||||
</RouterLink>
|
||||
<StoryMenuTree :tree :opened-directories="openedDirectories" @toggle-directory="toggleDirectory" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import StoryMenuTree from '@/components/component-story/StoryMenuTree.vue'
|
||||
import UiTitle from '@/components/ui/UiTitle.vue'
|
||||
import { ref } from 'vue'
|
||||
import { type RouteRecordNormalized, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<VtsModal
|
||||
:accent="isJsonValid ? 'info' : 'danger'"
|
||||
:accent="isJsonValid ? 'success' : 'danger'"
|
||||
class="json-editor-modal"
|
||||
icon="fa:code"
|
||||
@confirm="handleSubmit()"
|
||||
|
||||
@@ -26,11 +26,6 @@
|
||||
<VtsStatus :status="Boolean(pool.ha_enabled)" />
|
||||
</template>
|
||||
</VtsTabularKeyValueRow>
|
||||
<VtsTabularKeyValueRow :label="t('reboot-vm-internal-shutdown')">
|
||||
<template #value>
|
||||
<VtsStatus :status="pool.ha_reboot_vm_on_internal_shutdown ?? true" />
|
||||
</template>
|
||||
</VtsTabularKeyValueRow>
|
||||
<VtsTabularKeyValueRow :label="t('migration-compression')">
|
||||
<template #value>
|
||||
<VtsStatus :status="pool.migration_compression ?? false" />
|
||||
|
||||
65
@xen-orchestra/lite/src/components/ui/UiTitle.vue
Normal file
65
@xen-orchestra/lite/src/components/ui/UiTitle.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<component :is="tag" :class="type" class="ui-title">
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'display' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||
}>()
|
||||
|
||||
const tag = computed(() => {
|
||||
if (props.type === 'display') {
|
||||
return 'h1'
|
||||
}
|
||||
|
||||
return props.type
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-title {
|
||||
line-height: 150%;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
color: var(--color-neutral-txt-primary);
|
||||
|
||||
&.display {
|
||||
font-size: 6.4rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
&.h1 {
|
||||
font-size: 4.8rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
&.h2 {
|
||||
font-size: 3.6rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&.h3 {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
&.h4 {
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.h5 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.h6 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<UiCard class="vms-page-content">
|
||||
<UiTitle>
|
||||
<UiCardTitle subtitle>
|
||||
{{ t('vms') }}
|
||||
<template v-if="uiStore.isSmall" #right>
|
||||
<VmsActionsBar :selected-refs="selectedVmsRefs" />
|
||||
</template>
|
||||
</UiTitle>
|
||||
</UiCardTitle>
|
||||
<VmsActionsBar v-if="!uiStore.isSmall" :selected-refs="selectedVmsRefs" />
|
||||
<div>
|
||||
<div class="filter-and-sort">
|
||||
@@ -65,9 +65,9 @@ import VtsHeaderCell from '@core/components/table/cells/VtsHeaderCell.vue'
|
||||
import VtsRow from '@core/components/table/VtsRow.vue'
|
||||
import VtsTable from '@core/components/table/VtsTable.vue'
|
||||
import UiCard from '@core/components/ui/card/UiCard.vue'
|
||||
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
|
||||
import UiCheckbox from '@core/components/ui/checkbox/UiCheckbox.vue'
|
||||
import UiTableCell from '@core/components/ui/table-cell/UiTableCell.vue'
|
||||
import UiTitle from '@core/components/ui/title/UiTitle.vue'
|
||||
import { usePagination } from '@core/composables/pagination.composable'
|
||||
import { useTableState } from '@core/composables/table-state.composable'
|
||||
import { objectIcon } from '@core/icons'
|
||||
@@ -177,6 +177,7 @@ const { HeadCells, BodyCells } = useVmColumns({
|
||||
.vms-page-content {
|
||||
overflow: auto;
|
||||
margin: 1rem;
|
||||
gap: 0;
|
||||
|
||||
.filter-and-sort {
|
||||
display: flex;
|
||||
|
||||
@@ -123,7 +123,6 @@ export interface XenApiPool extends XenApiRecord<'pool'> {
|
||||
suspend_image_SR: XenApiSr['$ref'] | OPAQUE_REF_NULL
|
||||
crash_dump_SR: XenApiSr['$ref'] | OPAQUE_REF_NULL
|
||||
ha_statefiles: Array<XenApiVdi['$ref']>
|
||||
ha_reboot_vm_on_internal_shutdown?: boolean
|
||||
}
|
||||
|
||||
export interface XenApiHost extends XenApiRecord<'host'> {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<UiCard class="pool-hosts-view">
|
||||
<UiTitle>
|
||||
<UiCardTitle subtitle>
|
||||
{{ t('hosts') }}
|
||||
</UiTitle>
|
||||
</UiCardTitle>
|
||||
<VtsTable :state :pagination-bindings>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -20,13 +20,13 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiCard from '@/components/ui/UiCard.vue'
|
||||
import UiCardTitle from '@/components/ui/UiCardTitle.vue'
|
||||
import type { XenApiHost } from '@/libs/xen-api/xen-api.types'
|
||||
import { usePageTitleStore } from '@/stores/page-title.store'
|
||||
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store'
|
||||
import { useHostStore } from '@/stores/xen-api/host.store'
|
||||
import VtsRow from '@core/components/table/VtsRow.vue'
|
||||
import VtsTable from '@core/components/table/VtsTable.vue'
|
||||
import UiTitle from '@core/components/ui/title/UiTitle.vue'
|
||||
import { usePagination } from '@core/composables/pagination.composable'
|
||||
import { useTableState } from '@core/composables/table-state.composable'
|
||||
import { objectIcon } from '@core/icons'
|
||||
@@ -67,8 +67,5 @@ const { HeadCells, BodyCells } = useHostColumns({
|
||||
<style lang="postcss" scoped>
|
||||
.pool-hosts-view {
|
||||
margin: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.4rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<UiCard>
|
||||
<UiTitle>
|
||||
<UiTitle class="title-with-counter" type="h4">
|
||||
{{ t('tasks') }}
|
||||
<UiCounter :value="pendingTasks.length" accent="info" size="medium" variant="primary" />
|
||||
</UiTitle>
|
||||
@@ -10,11 +10,11 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import TasksTable from '@/components/tasks/TasksTable.vue'
|
||||
import UiCard from '@/components/ui/UiCard.vue'
|
||||
import UiTitle from '@/components/ui/UiTitle.vue'
|
||||
import { usePageTitleStore } from '@/stores/page-title.store'
|
||||
import { useTaskStore } from '@/stores/xen-api/task.store'
|
||||
import UiCard from '@core/components/ui/card/UiCard.vue'
|
||||
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
|
||||
import UiTitle from '@core/components/ui/title/UiTitle.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { pendingTasks, finishedTasks } = useTaskStore().subscribe()
|
||||
@@ -27,6 +27,13 @@ titleStore.setCount(() => pendingTasks.value.length)
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.title-with-counter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ui-card {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
1378
@xen-orchestra/lite/src/route-map.d.ts
vendored
1378
@xen-orchestra/lite/src/route-map.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,121 +0,0 @@
|
||||
<template>
|
||||
<ComponentStory
|
||||
v-slot="{ properties, settings }"
|
||||
:params="[
|
||||
prop('isOpen').bool().help('Controls drawer visibility'),
|
||||
prop('dismissible').bool().widget().help('Allow closing via backdrop click, dismiss button and Escape key'),
|
||||
prop('current').bool().widget().help('Only the current (topmost) drawer reacts to Escape'),
|
||||
event('dismiss').help('Emitted when the drawer is dismissed'),
|
||||
slot('title').help('Drawer title'),
|
||||
slot('content').help('Main content area'),
|
||||
slot('buttons').help('Footer buttons area (wrapped in VtsButtonGroup)'),
|
||||
setting('showForm').widget(boolean()).preset(false).help('Show a form example inside content'),
|
||||
]"
|
||||
:presets="{
|
||||
Default: {
|
||||
props: { isOpen: true, dismissible: true, current: true },
|
||||
},
|
||||
'With form': {
|
||||
props: { isOpen: true, dismissible: true, current: true },
|
||||
settings: { showForm: true },
|
||||
},
|
||||
}"
|
||||
>
|
||||
<UiButton variant="primary" accent="brand" size="medium" @click="isOpen = true">Open Drawer</UiButton>
|
||||
|
||||
<VtsDrawer
|
||||
:is-open="isOpen"
|
||||
:dismissible="properties.dismissible"
|
||||
:current="properties.current"
|
||||
@dismiss="handleDismiss()"
|
||||
>
|
||||
<template #title>Drawer Title</template>
|
||||
<template #content>
|
||||
<form v-if="settings.showForm" class="form" @submit.prevent="handleSubmit()">
|
||||
<VtsInputWrapper
|
||||
label="Name"
|
||||
:message="isNameEmpty ? { content: 'Name is required', accent: 'danger' } : undefined"
|
||||
>
|
||||
<UiInput v-model.trim="form.name" required :accent="isNameEmpty ? 'danger' : 'brand'" />
|
||||
</VtsInputWrapper>
|
||||
<VtsInputWrapper label="Description">
|
||||
<UiInput v-model="form.description" accent="brand" />
|
||||
</VtsInputWrapper>
|
||||
<div class="actions">
|
||||
<UiButton variant="secondary" accent="brand" size="medium" @click="handleDismiss()">Cancel</UiButton>
|
||||
<UiButton variant="primary" accent="brand" size="medium" type="submit" :disabled="isNameEmpty">
|
||||
Save
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
<p v-else>This is the drawer content.</p>
|
||||
</template>
|
||||
<template v-if="!settings.showForm" #buttons>
|
||||
<UiButton
|
||||
v-if="properties.dismissible"
|
||||
variant="secondary"
|
||||
accent="brand"
|
||||
size="medium"
|
||||
@click="handleDismiss()"
|
||||
>
|
||||
Cancel
|
||||
</UiButton>
|
||||
<UiButton variant="primary" accent="brand" size="medium" @click="handleDismiss()">Confirm</UiButton>
|
||||
</template>
|
||||
</VtsDrawer>
|
||||
</ComponentStory>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ComponentStory from '@/components/component-story/ComponentStory.vue'
|
||||
import { event, prop, setting, slot } from '@/libs/story/story-param'
|
||||
import { boolean } from '@/libs/story/story-widget'
|
||||
import VtsDrawer from '@core/components/drawer/VtsDrawer.vue'
|
||||
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
|
||||
import UiButton from '@core/components/ui/button/UiButton.vue'
|
||||
import UiInput from '@core/components/ui/input/UiInput.vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const isNameEmpty = computed(() => form.name.trim() === '')
|
||||
|
||||
function handleSubmit() {
|
||||
if (isNameEmpty.value) {
|
||||
return
|
||||
}
|
||||
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
isOpen.value = false
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.6rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.8rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<ComponentStory
|
||||
v-slot="{ properties, settings }"
|
||||
:params="[
|
||||
prop('accent').required().enum('info', 'warning', 'danger').preset('info').widget(),
|
||||
iconProp(),
|
||||
prop('dismissible').bool().widget(),
|
||||
prop('onDismiss').type('function').widget(),
|
||||
prop('onConfirm').type('function').widget(),
|
||||
prop('current').bool().widget(),
|
||||
event('confirm'),
|
||||
event('dismiss'),
|
||||
slot('title'),
|
||||
slot('content'),
|
||||
slot('buttons'),
|
||||
setting('titleSlotContent')
|
||||
.preset('Example content for title slot')
|
||||
.widget(text())
|
||||
.help('Content for title slot'),
|
||||
setting('contentSlotContent')
|
||||
.preset('Example content for content slot')
|
||||
.widget(text())
|
||||
.help('Content for content slot'),
|
||||
setting('showDemoButtons').widget(boolean()),
|
||||
]"
|
||||
>
|
||||
<VtsModal class="story" v-bind="properties">
|
||||
<template #title>{{ settings.titleSlotContent }}</template>
|
||||
<template #content>{{ settings.contentSlotContent }}</template>
|
||||
<template v-if="settings.showDemoButtons" #buttons>
|
||||
<VtsModalCancelButton>Cancel</VtsModalCancelButton>
|
||||
<VtsModalConfirmButton> Confirm </VtsModalConfirmButton>
|
||||
</template>
|
||||
</VtsModal>
|
||||
</ComponentStory>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ComponentStory from '@/components/component-story/ComponentStory.vue'
|
||||
import { iconProp, prop, event, slot, setting } from '@/libs/story/story-param'
|
||||
import { text, boolean } from '@/libs/story/story-widget'
|
||||
import VtsModal from '@core/components/modal/VtsModal.vue'
|
||||
import VtsModalCancelButton from '@core/components/modal/VtsModalCancelButton.vue'
|
||||
import VtsModalConfirmButton from '@core/components/modal/VtsModalConfirmButton.vue'
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.story {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<ComponentStory
|
||||
v-slot="{ properties, settings }"
|
||||
:params="[
|
||||
prop('title').str().widget().preset('Drawer Title'),
|
||||
prop('isOpen').bool().widget(),
|
||||
prop('isDismissible').bool().help('Show dismiss button and enable backdrop click'),
|
||||
event('dismiss').help('Emitted when dismiss button or backdrop is clicked'),
|
||||
slot('content').help('Main content area'),
|
||||
slot('buttons').help('Footer buttons area'),
|
||||
setting('dismissible').widget(boolean()).preset(true).help('Toggle dismiss behavior in this story'),
|
||||
setting('showButtons').widget(boolean()).preset(true),
|
||||
]"
|
||||
:presets="{
|
||||
Dismissible: {
|
||||
props: { isOpen: true },
|
||||
settings: { dismissible: true },
|
||||
},
|
||||
Persistent: {
|
||||
props: { isOpen: true },
|
||||
settings: { dismissible: false },
|
||||
},
|
||||
'No title': {
|
||||
props: { isOpen: true, title: '' },
|
||||
settings: { dismissible: true },
|
||||
},
|
||||
'No buttons': {
|
||||
props: { isOpen: true },
|
||||
settings: { dismissible: true, showButtons: false },
|
||||
},
|
||||
}"
|
||||
>
|
||||
<UiButton variant="primary" accent="brand" size="medium" @click="isOpen = true">Open Drawer</UiButton>
|
||||
|
||||
<UiDrawer :is-open :title="properties.title" :is-dismissible="settings.dismissible" @dismiss="isOpen = false">
|
||||
<template #content>
|
||||
<p>This is the drawer content.</p>
|
||||
</template>
|
||||
<template v-if="settings.showButtons" #buttons>
|
||||
<UiButton v-if="settings.dismissible" variant="secondary" accent="brand" size="medium" @click="isOpen = false">
|
||||
Cancel
|
||||
</UiButton>
|
||||
<UiButton variant="primary" accent="brand" size="medium" @click="isOpen = false">Confirm</UiButton>
|
||||
</template>
|
||||
</UiDrawer>
|
||||
</ComponentStory>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ComponentStory from '@/components/component-story/ComponentStory.vue'
|
||||
import { event, prop, setting, slot } from '@/libs/story/story-param'
|
||||
import { boolean } from '@/libs/story/story-widget'
|
||||
import UiButton from '@core/components/ui/button/UiButton.vue'
|
||||
import UiDrawer from '@core/components/ui/drawer/UiDrawer.vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isOpen = ref(false)
|
||||
</script>
|
||||
@@ -1,49 +0,0 @@
|
||||
<template>
|
||||
<ComponentStory
|
||||
v-slot="{ properties, settings }"
|
||||
:params="[
|
||||
prop('accent').required().enum('info', 'warning', 'danger').preset('info').widget(),
|
||||
iconProp(),
|
||||
prop('dismissible').bool().widget(),
|
||||
prop('onDismiss').type('function').widget(),
|
||||
event('dismiss'),
|
||||
slot('title'),
|
||||
slot('content'),
|
||||
slot('buttons'),
|
||||
setting('titleSlotContent')
|
||||
.preset('Example content for title slot')
|
||||
.widget(text())
|
||||
.help('Content for title slot'),
|
||||
setting('contentSlotContent')
|
||||
.preset('Example content for content slot')
|
||||
.widget(text())
|
||||
.help('Content for content slot'),
|
||||
setting('showDemoButton').widget(boolean()).preset(true),
|
||||
]"
|
||||
>
|
||||
<UiModal class="story" v-bind="properties">
|
||||
<template #title>{{ settings.titleSlotContent }}</template>
|
||||
<template #content>{{ settings.contentSlotContent }}</template>
|
||||
<template v-if="settings.showDemoButton" #buttons>
|
||||
<UiButton :accent="properties.accent === 'info' ? 'brand' : properties.accent" variant="primary" size="medium">
|
||||
Confirm
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</ComponentStory>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ComponentStory from '@/components/component-story/ComponentStory.vue'
|
||||
import { iconProp, prop, event, slot, setting } from '@/libs/story/story-param'
|
||||
import { text, boolean } from '@/libs/story/story-widget'
|
||||
import UiButton from '@core/components/ui/button/UiButton.vue'
|
||||
import UiModal from '@core/components/ui/modal/UiModal.vue'
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.story {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -50,7 +50,6 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
'@core': fileURLToPath(new URL('../web-core/lib', import.meta.url)),
|
||||
},
|
||||
dedupe: ['vue', 'pinia', 'vue-router'],
|
||||
},
|
||||
|
||||
// https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies
|
||||
|
||||
@@ -65,11 +65,9 @@ See the [full documentation](https://docs.xen-orchestra.com/mcp) for configurati
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** >= 20
|
||||
- **Xen Orchestra** 6.5 or later, with the [REST API](https://docs.xen-orchestra.com/restapi) enabled
|
||||
- **Xen Orchestra** instance with [REST API](https://docs.xen-orchestra.com/restapi) enabled
|
||||
- An **AI assistant** that supports MCP (Claude Desktop, Claude Code, etc.)
|
||||
|
||||
> **Note:** this MCP server requires **Xen Orchestra 6.5 or later**. On an older XO it stops at startup with an error such as `Unable to verify MCP status (HTTP 404)` — upgrade your Xen Orchestra to use it.
|
||||
|
||||
### Configuration
|
||||
|
||||
Two authentication modes are supported: **token** (recommended) or **username/password**.
|
||||
@@ -147,20 +145,6 @@ Stats endpoints (`/…/stats`) and binary download endpoints (`.xva`, `.ova`, `.
|
||||
|
||||
### Write operations
|
||||
|
||||
Action tools (create/update/delete) are gated behind `XO_MCP_ENABLE_ACTIONS=1` so the default surface stays read-only. When enabled, each domain gets a companion `{domain}_action` tool. Every action — from creating a snapshot to a `DELETE` or `pools/emergency_shutdown` — returns a preview and a one-shot `confirm_token` the assistant must send back within 5 minutes to execute.
|
||||
|
||||
### Security model
|
||||
|
||||
`x-mcp-exposure` controls **which REST endpoints this MCP server turns into LLM tools — it is not a REST API security boundary.**
|
||||
|
||||
- The XO REST API never reads `x-mcp-exposure`; the annotation lives only in the OpenAPI spec and is consumed solely by this server's tool generation. Every endpoint keeps its own RBAC/ACL authorization, which remains the actual access-control gate.
|
||||
- Any REST client with valid credentials — including a non-official or compromised MCP — can call a `deny`-tagged endpoint directly. The annotation cannot stop it.
|
||||
- The benefit is a **narrower threat model**: limiting what an LLM can see and invoke (a guard against prompt injection and model mistakes), not authorization.
|
||||
|
||||
In short, `x-mcp-exposure` (and the `mcp/require-mcp-expose` lint rule that enforces it on the REST side) is a _surface-control policy_ for the assistant's toolset. Rely on XO's RBAC/ACL for "who may do what".
|
||||
|
||||
### Server-side kill-switch
|
||||
|
||||
An xo-server admin can globally block MCP by setting `[mcp] enabled = false` in the server config. With that flag set, the binary exits at startup with `MCP disabled by admin` on stderr and any active client receives `503 { "error": "mcp_disabled" }` on its next request. It's not a bug — re-enable MCP in xo-server's config to recover.
|
||||
Action tools (create/update/delete) are gated behind `XO_MCP_ENABLE_ACTIONS=1` so the default surface stays read-only. When enabled, each domain gets a companion `{domain}_action` tool. Destructive operations (`DELETE *`, `pools/emergency_shutdown`, `vms/hard_shutdown`, …) return a preview and a one-shot `confirm_token` — the assistant must call back with that token within 5 minutes to execute.
|
||||
|
||||
Full documentation with tool parameters, examples, and troubleshooting: [docs.xen-orchestra.com/mcp](https://docs.xen-orchestra.com/mcp)
|
||||
|
||||
@@ -83,11 +83,9 @@ See the [full documentation](https://docs.xen-orchestra.com/mcp) for configurati
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** >= 20
|
||||
- **Xen Orchestra** 6.5 or later, with the [REST API](https://docs.xen-orchestra.com/restapi) enabled
|
||||
- **Xen Orchestra** instance with [REST API](https://docs.xen-orchestra.com/restapi) enabled
|
||||
- An **AI assistant** that supports MCP (Claude Desktop, Claude Code, etc.)
|
||||
|
||||
> **Note:** this MCP server requires **Xen Orchestra 6.5 or later**. On an older XO it stops at startup with an error such as `Unable to verify MCP status (HTTP 404)` — upgrade your Xen Orchestra to use it.
|
||||
|
||||
### Configuration
|
||||
|
||||
Two authentication modes are supported: **token** (recommended) or **username/password**.
|
||||
@@ -165,21 +163,7 @@ Stats endpoints (`/…/stats`) and binary download endpoints (`.xva`, `.ova`, `.
|
||||
|
||||
### Write operations
|
||||
|
||||
Action tools (create/update/delete) are gated behind `XO_MCP_ENABLE_ACTIONS=1` so the default surface stays read-only. When enabled, each domain gets a companion `{domain}_action` tool. Every action — from creating a snapshot to a `DELETE` or `pools/emergency_shutdown` — returns a preview and a one-shot `confirm_token` the assistant must send back within 5 minutes to execute.
|
||||
|
||||
### Security model
|
||||
|
||||
`x-mcp-exposure` controls **which REST endpoints this MCP server turns into LLM tools — it is not a REST API security boundary.**
|
||||
|
||||
- The XO REST API never reads `x-mcp-exposure`; the annotation lives only in the OpenAPI spec and is consumed solely by this server's tool generation. Every endpoint keeps its own RBAC/ACL authorization, which remains the actual access-control gate.
|
||||
- Any REST client with valid credentials — including a non-official or compromised MCP — can call a `deny`-tagged endpoint directly. The annotation cannot stop it.
|
||||
- The benefit is a **narrower threat model**: limiting what an LLM can see and invoke (a guard against prompt injection and model mistakes), not authorization.
|
||||
|
||||
In short, `x-mcp-exposure` (and the `mcp/require-mcp-expose` lint rule that enforces it on the REST side) is a _surface-control policy_ for the assistant's toolset. Rely on XO's RBAC/ACL for "who may do what".
|
||||
|
||||
### Server-side kill-switch
|
||||
|
||||
An xo-server admin can globally block MCP by setting `[mcp] enabled = false` in the server config. With that flag set, the binary exits at startup with `MCP disabled by admin` on stderr and any active client receives `503 { "error": "mcp_disabled" }` on its next request. It's not a bug — re-enable MCP in xo-server's config to recover.
|
||||
Action tools (create/update/delete) are gated behind `XO_MCP_ENABLE_ACTIONS=1` so the default surface stays read-only. When enabled, each domain gets a companion `{domain}_action` tool. Destructive operations (`DELETE *`, `pools/emergency_shutdown`, `vms/hard_shutdown`, …) return a preview and a one-shot `confirm_token` — the assistant must call back with that token within 5 minutes to execute.
|
||||
|
||||
Full documentation with tool parameters, examples, and troubleshooting: [docs.xen-orchestra.com/mcp](https://docs.xen-orchestra.com/mcp)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/mcp",
|
||||
"version": "1.4.0",
|
||||
"version": "1.2.0",
|
||||
"description": "MCP server for Xen Orchestra — allows AI assistants to query and manage XO infrastructure",
|
||||
"type": "module",
|
||||
"main": "./dist/index.mjs",
|
||||
@@ -34,7 +34,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@types/node": "^22.3.0",
|
||||
"@vates/types": "^1.26.0",
|
||||
"@vates/types": "^1.24.0",
|
||||
"rimraf": "^6.0.0",
|
||||
"typescript": "~5.6.3",
|
||||
"typescript-eslint": "^8.23.0"
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export type RiskLevel = 'direct' | 'confirm'
|
||||
|
||||
/**
|
||||
* Patterns matched against the incoming operation:
|
||||
* - `METHOD *` → any call using that HTTP method (e.g. all DELETEs)
|
||||
* - bare string → an exact operationId (the `operationId` from the spec)
|
||||
*/
|
||||
const CONFIRM_PATTERNS: string[] = [
|
||||
'DELETE *',
|
||||
'EmergencyShutdownPool',
|
||||
'RollingReboot',
|
||||
'RollingUpdate',
|
||||
'HardShutdownVm',
|
||||
'HardRebootVm',
|
||||
]
|
||||
|
||||
export function getRiskLevel(method: string, operationId: string): RiskLevel {
|
||||
const upperMethod = method.toUpperCase()
|
||||
for (const pattern of CONFIRM_PATTERNS) {
|
||||
if (pattern.endsWith(' *')) {
|
||||
if (upperMethod === pattern.slice(0, -2)) return 'confirm'
|
||||
continue
|
||||
}
|
||||
if (operationId === pattern) return 'confirm'
|
||||
}
|
||||
return 'direct'
|
||||
}
|
||||
|
||||
interface PendingConfirmation {
|
||||
token: string
|
||||
method: string
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it, afterEach } from 'node:test'
|
||||
import { createConfirmation, consumeConfirmation, getPendingCount, clearPending } from './risk-config.mjs'
|
||||
import { getRiskLevel, createConfirmation, consumeConfirmation, getPendingCount, clearPending } from './risk-config.mjs'
|
||||
|
||||
describe('getRiskLevel', () => {
|
||||
it('classifies any DELETE as confirm', () => {
|
||||
assert.strictEqual(getRiskLevel('DELETE', 'DeleteVm'), 'confirm')
|
||||
assert.strictEqual(getRiskLevel('DELETE', 'DeletePool'), 'confirm')
|
||||
assert.strictEqual(getRiskLevel('DELETE', 'DeleteWhatever'), 'confirm')
|
||||
})
|
||||
|
||||
it('classifies specific dangerous operationIds as confirm', () => {
|
||||
assert.strictEqual(getRiskLevel('POST', 'EmergencyShutdownPool'), 'confirm')
|
||||
assert.strictEqual(getRiskLevel('POST', 'RollingReboot'), 'confirm')
|
||||
assert.strictEqual(getRiskLevel('POST', 'RollingUpdate'), 'confirm')
|
||||
assert.strictEqual(getRiskLevel('POST', 'HardShutdownVm'), 'confirm')
|
||||
assert.strictEqual(getRiskLevel('POST', 'HardRebootVm'), 'confirm')
|
||||
})
|
||||
|
||||
it('classifies safe operations as direct', () => {
|
||||
assert.strictEqual(getRiskLevel('POST', 'StartVm'), 'direct')
|
||||
assert.strictEqual(getRiskLevel('POST', 'CleanShutdownVm'), 'direct')
|
||||
assert.strictEqual(getRiskLevel('POST', 'SnapshotVm'), 'direct')
|
||||
assert.strictEqual(getRiskLevel('GET', 'GetVms'), 'direct')
|
||||
})
|
||||
|
||||
it('is case-insensitive on method', () => {
|
||||
assert.strictEqual(getRiskLevel('delete', 'DeleteVm'), 'confirm')
|
||||
assert.strictEqual(getRiskLevel('Delete', 'DeleteVm'), 'confirm')
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmation tokens', () => {
|
||||
afterEach(() => {
|
||||
|
||||
@@ -4,10 +4,6 @@ import { getProxyDispatcher, type FetchInit } from '../utils/proxy.mjs'
|
||||
const FETCH_TIMEOUT_MS = 10_000
|
||||
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const
|
||||
|
||||
/** Per-endpoint MCP exposure decision, set by REST authors via `@Extension`. */
|
||||
export const MCP_EXPOSURE_EXTENSION_KEY = 'x-mcp-exposure'
|
||||
export type McpExposure = 'allow' | 'confirm' | 'deny'
|
||||
|
||||
interface OpenApiExample {
|
||||
value?: unknown
|
||||
}
|
||||
@@ -28,7 +24,6 @@ export interface OpenApiOperation {
|
||||
description?: string
|
||||
tags?: string[]
|
||||
responses?: Record<string, OpenApiResponse>
|
||||
[vendorExtension: `x-${string}`]: unknown
|
||||
}
|
||||
|
||||
interface OpenApiPathItem {
|
||||
@@ -53,8 +48,6 @@ export interface Operation {
|
||||
* their own docs. Undefined when the spec has no usable example.
|
||||
*/
|
||||
defaultFields?: string
|
||||
/** True when the endpoint requires a confirm-token round-trip before execution. */
|
||||
requiresConfirm: boolean
|
||||
}
|
||||
|
||||
export interface Domain {
|
||||
@@ -66,8 +59,6 @@ export interface Domain {
|
||||
export interface ParseOptions {
|
||||
/** operationIds to skip entirely (never exposed as a tool enum value). */
|
||||
denyList?: Iterable<string>
|
||||
/** Include `'confirm'`-tagged operations. Gated by `XO_MCP_ENABLE_ACTIONS=1`. */
|
||||
includeConfirm?: boolean
|
||||
}
|
||||
|
||||
export async function fetchSwaggerSpec(baseUrl: string, authHeaders: Record<string, string>): Promise<OpenApiSpec> {
|
||||
@@ -130,12 +121,6 @@ function extractDefaultFields(op: OpenApiOperation): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getExposure(op: OpenApiOperation): McpExposure | undefined {
|
||||
const raw = op[MCP_EXPOSURE_EXTENSION_KEY]
|
||||
if (raw === 'allow' || raw === 'confirm' || raw === 'deny') return raw
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Group operations by the **first path segment** — sub-resources (e.g.
|
||||
* `/hosts/{id}/alarms`, `/pools/{id}/messages`) land under their parent
|
||||
@@ -143,14 +128,9 @@ function getExposure(op: OpenApiOperation): McpExposure | undefined {
|
||||
* Server-side tags are ignored here because tsoa emits the method-level tag
|
||||
* first (`tags[0] = 'alarms'` on `/hosts/{id}/alarms`), which would route the
|
||||
* endpoint away from its parent.
|
||||
*
|
||||
* Filters applied in order — route-filter BEFORE exposure so a binary endpoint
|
||||
* mistakenly tagged `'allow'` still stays out of the MCP surface (defense in
|
||||
* depth): route-filter regex → denyList → `x-mcp-exposure` (default deny).
|
||||
*/
|
||||
export function parseSwagger(spec: OpenApiSpec, opts: ParseOptions = {}): Map<string, Domain> {
|
||||
const deny = new Set(opts.denyList ?? [])
|
||||
const includeConfirm = opts.includeConfirm === true
|
||||
const domains = new Map<string, Domain>()
|
||||
|
||||
for (const [path, pathItem] of Object.entries(spec.paths)) {
|
||||
@@ -164,10 +144,6 @@ export function parseSwagger(spec: OpenApiSpec, opts: ParseOptions = {}): Map<st
|
||||
const operationId = op.operationId ?? deriveOperationId(method, path)
|
||||
if (deny.has(operationId)) continue
|
||||
|
||||
const exposure = getExposure(op)
|
||||
if (exposure === undefined || exposure === 'deny') continue
|
||||
if (exposure === 'confirm' && !includeConfirm) continue
|
||||
|
||||
const tag = path.replace(/^\//, '').split('/')[0]
|
||||
|
||||
if (!domains.has(tag)) domains.set(tag, { tag, queryOps: [], actionOps: [] })
|
||||
@@ -179,7 +155,6 @@ export function parseSwagger(spec: OpenApiSpec, opts: ParseOptions = {}): Map<st
|
||||
path,
|
||||
description: op.summary ?? op.description ?? `${method.toUpperCase()} ${path}`,
|
||||
defaultFields: method === 'get' ? extractDefaultFields(op) : undefined,
|
||||
requiresConfirm: exposure === 'confirm',
|
||||
}
|
||||
;(method === 'get' ? domain.queryOps : domain.actionOps).push(operation)
|
||||
}
|
||||
|
||||
@@ -1,41 +1,11 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { parseSwagger, type OpenApiSpec, type OpenApiOperation } from './swagger.mjs'
|
||||
import { parseSwagger, type OpenApiSpec } from './swagger.mjs'
|
||||
|
||||
type Exposure = 'allow' | 'confirm' | 'deny'
|
||||
|
||||
/** Build a spec where every operation defaults to `x-mcp-exposure: 'allow'`. */
|
||||
function spec(paths: OpenApiSpec['paths']): OpenApiSpec {
|
||||
return { info: { title: 'Test', version: '1.0.0' }, paths: applyDefaultExposure(paths) }
|
||||
}
|
||||
|
||||
function applyDefaultExposure(paths: OpenApiSpec['paths']): OpenApiSpec['paths'] {
|
||||
const out: OpenApiSpec['paths'] = {}
|
||||
for (const [path, item] of Object.entries(paths)) {
|
||||
if (!item) continue
|
||||
const newItem: Record<string, OpenApiOperation | undefined> = {}
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
if (!operation) continue
|
||||
newItem[method] =
|
||||
operation['x-mcp-exposure'] !== undefined ? operation : { ...operation, 'x-mcp-exposure': 'allow' }
|
||||
}
|
||||
out[path] = newItem
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Build a spec without any auto-defaulting, for exposure-focused tests. */
|
||||
function rawSpec(paths: OpenApiSpec['paths']): OpenApiSpec {
|
||||
return { info: { title: 'Test', version: '1.0.0' }, paths }
|
||||
}
|
||||
|
||||
function op(props: Partial<OpenApiOperation> & { exposure?: Exposure }): OpenApiOperation {
|
||||
const { exposure, ...rest } = props
|
||||
const result = { ...rest } as OpenApiOperation
|
||||
if (exposure !== undefined) result['x-mcp-exposure'] = exposure
|
||||
return result
|
||||
}
|
||||
|
||||
describe('parseSwagger', () => {
|
||||
it('uses operationId as the operation name', () => {
|
||||
const domains = parseSwagger(
|
||||
@@ -55,6 +25,8 @@ describe('parseSwagger', () => {
|
||||
})
|
||||
|
||||
it('groups sub-resources under their parent path segment', () => {
|
||||
// Path-first: `/pools/{id}/alarms` lands in `pools`, not `alarms`.
|
||||
// This matches how an agent navigates ("alarms OF a given pool").
|
||||
const domains = parseSwagger(
|
||||
spec({
|
||||
'/alarms': { get: { tags: ['alarms'], operationId: 'GetAlarms' } },
|
||||
@@ -177,121 +149,4 @@ describe('parseSwagger', () => {
|
||||
const domains = parseSwagger(spec({ '/custom-thing': { get: { operationId: 'GetCustomThing' } } }))
|
||||
assert.ok(domains.has('custom-thing'))
|
||||
})
|
||||
|
||||
describe('x-mcp-exposure filtering', () => {
|
||||
it("exposes an operation tagged 'allow'", () => {
|
||||
const domains = parseSwagger(
|
||||
rawSpec({
|
||||
'/vms': { get: op({ operationId: 'GetVms', exposure: 'allow' }) },
|
||||
})
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
domains.get('vms')!.queryOps.map(o => o.name),
|
||||
['GetVms']
|
||||
)
|
||||
assert.strictEqual(domains.get('vms')!.queryOps[0].requiresConfirm, false)
|
||||
})
|
||||
|
||||
it("drops an operation tagged 'deny'", () => {
|
||||
const domains = parseSwagger(
|
||||
rawSpec({
|
||||
'/vms': { get: op({ operationId: 'GetVms', exposure: 'deny' }) },
|
||||
})
|
||||
)
|
||||
assert.strictEqual(domains.size, 0)
|
||||
})
|
||||
|
||||
it('drops an operation without any x-mcp-exposure (default deny)', () => {
|
||||
const domains = parseSwagger(
|
||||
rawSpec({
|
||||
'/vms': { get: op({ operationId: 'GetVms' }) },
|
||||
})
|
||||
)
|
||||
assert.strictEqual(domains.size, 0)
|
||||
})
|
||||
|
||||
it("drops an operation tagged 'confirm' when includeConfirm is false (default)", () => {
|
||||
const domains = parseSwagger(
|
||||
rawSpec({
|
||||
'/vms/{id}': { delete: op({ operationId: 'DeleteVm', exposure: 'confirm' }) },
|
||||
})
|
||||
)
|
||||
assert.strictEqual(domains.size, 0)
|
||||
})
|
||||
|
||||
it("includes an operation tagged 'confirm' when includeConfirm is true, marking requiresConfirm", () => {
|
||||
const domains = parseSwagger(
|
||||
rawSpec({
|
||||
'/vms/{id}': { delete: op({ operationId: 'DeleteVm', exposure: 'confirm' }) },
|
||||
}),
|
||||
{ includeConfirm: true }
|
||||
)
|
||||
const vm = domains.get('vms')!
|
||||
assert.strictEqual(vm.actionOps.length, 1)
|
||||
assert.strictEqual(vm.actionOps[0].name, 'DeleteVm')
|
||||
assert.strictEqual(vm.actionOps[0].requiresConfirm, true)
|
||||
})
|
||||
|
||||
it("'allow' operations have requiresConfirm=false even when includeConfirm is true", () => {
|
||||
const domains = parseSwagger(
|
||||
rawSpec({
|
||||
'/vms': { post: op({ operationId: 'CreateVm', exposure: 'allow' }) },
|
||||
}),
|
||||
{ includeConfirm: true }
|
||||
)
|
||||
assert.strictEqual(domains.get('vms')!.actionOps[0].requiresConfirm, false)
|
||||
})
|
||||
|
||||
it('mixes allow/confirm/deny across operations under the same path correctly', () => {
|
||||
const domains = parseSwagger(
|
||||
rawSpec({
|
||||
'/vms/{id}': {
|
||||
get: op({ operationId: 'GetVm', exposure: 'allow' }),
|
||||
put: op({ operationId: 'UpdateVm', exposure: 'confirm' }),
|
||||
delete: op({ operationId: 'DeleteVm', exposure: 'deny' }),
|
||||
patch: op({ operationId: 'PatchVm' }),
|
||||
},
|
||||
}),
|
||||
{ includeConfirm: true }
|
||||
)
|
||||
const vms = domains.get('vms')!
|
||||
assert.deepStrictEqual(
|
||||
vms.queryOps.map(o => o.name),
|
||||
['GetVm']
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
vms.actionOps.map(o => o.name),
|
||||
['UpdateVm']
|
||||
)
|
||||
})
|
||||
|
||||
it('route-filter (stats/binaries) wins over any exposure tag (defense in depth)', () => {
|
||||
const domains = parseSwagger(
|
||||
rawSpec({
|
||||
'/pools/{id}/stats': { get: op({ operationId: 'GetPoolStats', exposure: 'allow' }) },
|
||||
'/backup-archives/{id}.tgz': { get: op({ operationId: 'DownloadArchive', exposure: 'allow' }) },
|
||||
})
|
||||
)
|
||||
assert.strictEqual(domains.size, 0, 'binaries/stats must stay excluded even when tagged allow')
|
||||
})
|
||||
|
||||
it('denyList wins over exposure tag', () => {
|
||||
const domains = parseSwagger(
|
||||
rawSpec({
|
||||
'/vms': { get: op({ operationId: 'GetVms', exposure: 'allow' }) },
|
||||
}),
|
||||
{ denyList: ['GetVms'] }
|
||||
)
|
||||
assert.strictEqual(domains.size, 0)
|
||||
})
|
||||
|
||||
it('treats an unknown exposure value as deny', () => {
|
||||
const spec = rawSpec({
|
||||
'/vms': { get: { tags: ['vms'], operationId: 'GetVms' } as OpenApiOperation },
|
||||
})
|
||||
;(spec.paths['/vms']!.get as OpenApiOperation)['x-mcp-exposure'] = 'somethingElse' as never
|
||||
const domains = parseSwagger(spec)
|
||||
assert.strictEqual(domains.size, 0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { z } from 'zod'
|
||||
import type { XoClient } from '../xo-client.mjs'
|
||||
import type { Domain, Operation } from './swagger.mjs'
|
||||
import { createConfirmation, consumeConfirmation } from './risk-config.mjs'
|
||||
import { getRiskLevel, createConfirmation, consumeConfirmation } from './risk-config.mjs'
|
||||
import { formatToolError } from '../helpers/tool-error.mjs'
|
||||
|
||||
function describeOps(ops: Operation[]): string {
|
||||
@@ -112,7 +112,7 @@ export function registerActionTool(server: McpServer, getClient: () => XoClient,
|
||||
}
|
||||
|
||||
const path = resolvePath(op.path, id)
|
||||
if (op.requiresConfirm) {
|
||||
if (getRiskLevel(op.method, operation) === 'confirm') {
|
||||
const token = createConfirmation(op.method, path, body)
|
||||
const preview = [
|
||||
'## Confirmation Required',
|
||||
@@ -145,7 +145,14 @@ export function registerActionTool(server: McpServer, getClient: () => XoClient,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Action tools (write ops, with per-operation confirmation for destructive ones)
|
||||
* are gated behind XO_MCP_ENABLE_ACTIONS so the default MCP surface stays
|
||||
* read-only. Set XO_MCP_ENABLE_ACTIONS=1 to opt in.
|
||||
*/
|
||||
const actionsEnabled = process.env.XO_MCP_ENABLE_ACTIONS === '1'
|
||||
|
||||
export function registerDomainTools(server: McpServer, getClient: () => XoClient, domain: Domain): void {
|
||||
registerQueryTool(server, getClient, domain)
|
||||
registerActionTool(server, getClient, domain)
|
||||
if (actionsEnabled) registerActionTool(server, getClient, domain)
|
||||
}
|
||||
|
||||
@@ -47,8 +47,6 @@ export function validateEnv(): EnvConfig {
|
||||
export async function main() {
|
||||
const env = validateEnv()
|
||||
|
||||
await XoClient.assertMcpEnabled(env.url)
|
||||
|
||||
let xoClient: XoClient | null = null
|
||||
function getClient(): XoClient {
|
||||
if (!xoClient) xoClient = new XoClient(env)
|
||||
|
||||
@@ -8,15 +8,13 @@ import { registerSearchDocs } from './tools/utility/search-docs.mjs'
|
||||
import { registerGetInfrastructureSummary } from './tools/utility/get-infrastructure-summary.mjs'
|
||||
|
||||
function parseEnvOverrides(): ParseOptions {
|
||||
const opts: ParseOptions = {}
|
||||
if (process.env.XO_MCP_ENABLE_ACTIONS === '1') opts.includeConfirm = true
|
||||
|
||||
const denyList = process.env.XO_MCP_DENY_LIST?.split(',')
|
||||
const denyRaw = process.env.XO_MCP_DENY_LIST
|
||||
if (!denyRaw) return {}
|
||||
const denyList = denyRaw
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
if (denyList?.length) opts.denyList = denyList
|
||||
|
||||
return opts
|
||||
return denyList.length > 0 ? { denyList } : {}
|
||||
}
|
||||
|
||||
export async function createServer(getClient: () => XoClient): Promise<McpServer> {
|
||||
|
||||
@@ -11,22 +11,12 @@ const MOCK_SWAGGER_SPEC = {
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'XO API', version: '1.0.0' },
|
||||
paths: {
|
||||
'/pools': {
|
||||
get: { tags: ['pools'], operationId: 'GetPools', summary: 'List all pools', 'x-mcp-exposure': 'allow' },
|
||||
},
|
||||
'/pools/{id}': {
|
||||
get: { tags: ['pools'], operationId: 'GetPool', summary: 'Get a pool', 'x-mcp-exposure': 'allow' },
|
||||
},
|
||||
'/vms': { get: { tags: ['vms'], operationId: 'GetVms', summary: 'List all VMs', 'x-mcp-exposure': 'allow' } },
|
||||
'/vms/{id}': {
|
||||
get: { tags: ['vms'], operationId: 'GetVm', summary: 'Get a VM', 'x-mcp-exposure': 'allow' },
|
||||
},
|
||||
'/vms/{id}/actions/start': {
|
||||
post: { tags: ['vms'], operationId: 'StartVm', summary: 'Start a VM', 'x-mcp-exposure': 'confirm' },
|
||||
},
|
||||
'/hosts': {
|
||||
get: { tags: ['hosts'], operationId: 'GetHosts', summary: 'List all hosts', 'x-mcp-exposure': 'allow' },
|
||||
},
|
||||
'/pools': { get: { tags: ['pools'], operationId: 'GetPools', summary: 'List all pools' } },
|
||||
'/pools/{id}': { get: { tags: ['pools'], operationId: 'GetPool', summary: 'Get a pool' } },
|
||||
'/vms': { get: { tags: ['vms'], operationId: 'GetVms', summary: 'List all VMs' } },
|
||||
'/vms/{id}': { get: { tags: ['vms'], operationId: 'GetVm', summary: 'Get a VM' } },
|
||||
'/vms/{id}/actions/start': { post: { tags: ['vms'], operationId: 'StartVm', summary: 'Start a VM' } },
|
||||
'/hosts': { get: { tags: ['hosts'], operationId: 'GetHosts', summary: 'List all hosts' } },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { getProxyDispatcher, type FetchInit } from './utils/proxy.mjs'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const BOOT_CHECK_TIMEOUT_MS = 10_000
|
||||
|
||||
// Identifies outgoing requests as coming from the MCP binary so the
|
||||
// xo-server `mcp-gate` middleware can apply the global kill-switch.
|
||||
export const MCP_CLIENT_HEADER: Readonly<Record<string, string>> = Object.freeze({ 'X-XO-Client': 'mcp' })
|
||||
|
||||
// Wire-level error code returned by xo-server when the MCP kill-switch is on.
|
||||
const MCP_DISABLED_ERROR = 'mcp_disabled'
|
||||
|
||||
export type XoClientConfig = { url: string; username: string; password: string } | { url: string; token: string }
|
||||
|
||||
@@ -17,51 +9,6 @@ export class XoClient {
|
||||
private readonly authHeaders: Record<string, string>
|
||||
private readonly authMode: 'token' | 'basic'
|
||||
|
||||
/**
|
||||
* Probes the XO server's `/rest/v0/mcp/status` endpoint to verify that the
|
||||
* MCP kill-switch is not engaged. Throws with a human-readable error when
|
||||
* the server is unreachable or when MCP has been disabled by the admin.
|
||||
*/
|
||||
static async assertMcpEnabled(xoUrl: string): Promise<void> {
|
||||
const url = `${xoUrl.replace(/\/$/, '')}/rest/v0/mcp/status`
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
const init: FetchInit = {
|
||||
headers: { ...MCP_CLIENT_HEADER },
|
||||
signal: AbortSignal.timeout(BOOT_CHECK_TIMEOUT_MS),
|
||||
dispatcher: getProxyDispatcher(),
|
||||
}
|
||||
response = await fetch(url, init)
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause)
|
||||
throw new Error(`Unable to reach XO server at ${xoUrl} to verify MCP status: ${message}`, { cause })
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
// A fetch Response body can only be read once, so read it as text first and
|
||||
// then attempt to parse it as JSON instead of chaining `.json()` and `.text()`
|
||||
// — the second call would throw "Body is unusable".
|
||||
const text = await response.text().catch(() => '')
|
||||
let body: { data?: { error?: string } } | undefined
|
||||
try {
|
||||
body = text === '' ? undefined : (JSON.parse(text) as { data?: { error?: string } })
|
||||
} catch {
|
||||
body = undefined
|
||||
}
|
||||
|
||||
// xo-server serializes ApiError as `{ error: <human message>, data: { error: 'mcp_disabled' } }`,
|
||||
// so the machine-readable code lives under `data.error`, not at the top level.
|
||||
if (response.status === 503 && body?.data?.error === MCP_DISABLED_ERROR) {
|
||||
throw new Error('MCP disabled by admin')
|
||||
}
|
||||
|
||||
throw new Error(`Unable to verify MCP status (HTTP ${response.status}): ${text || response.statusText}`)
|
||||
}
|
||||
|
||||
constructor(config: XoClientConfig) {
|
||||
this.baseUrl = config.url.replace(/\/$/, '')
|
||||
|
||||
@@ -82,7 +29,7 @@ export class XoClient {
|
||||
try {
|
||||
const init: FetchInit = {
|
||||
...options,
|
||||
headers: { ...MCP_CLIENT_HEADER, ...this.authHeaders, ...options.headers },
|
||||
headers: { ...this.authHeaders, ...options.headers },
|
||||
signal: options.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
dispatcher: getProxyDispatcher(),
|
||||
}
|
||||
|
||||
@@ -44,16 +44,6 @@ describe('XoClient', () => {
|
||||
}
|
||||
await client.testConnection()
|
||||
})
|
||||
|
||||
it('sends X-XO-Client: mcp on every request', async () => {
|
||||
const client = new XoClient({ url: 'http://xo.local:9000', token: 'my-token' })
|
||||
globalThis.fetch = async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = init?.headers as Record<string, string>
|
||||
assert.strictEqual(headers?.['X-XO-Client'], 'mcp')
|
||||
return new Response('ok')
|
||||
}
|
||||
await client.testConnection()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetch', () => {
|
||||
|
||||
@@ -67,46 +67,42 @@ export default class Tasks extends EventEmitter {
|
||||
this.#handleOnProgressScheduled = false
|
||||
}
|
||||
|
||||
#bufferTaskUpdate = taskLog => {
|
||||
const buf = this.#onProgressBuffer
|
||||
|
||||
if (!this.#handleOnProgressScheduled) {
|
||||
this.#handleOnProgressScheduled = true
|
||||
|
||||
// task events are buffered, deduplicated and will be handled one after
|
||||
// the others on the next tick to avoid race conditions
|
||||
process.nextTick(this.#handleOnProgressBuffer)
|
||||
}
|
||||
|
||||
// Error objects are not JSON-ifiable by default
|
||||
const { result } = taskLog
|
||||
if (result instanceof Error && result.toJSON === undefined) {
|
||||
taskLog.result = serializeError(result)
|
||||
}
|
||||
|
||||
const { $root } = taskLog
|
||||
const { status, id } = $root
|
||||
if (status !== 'pending') {
|
||||
if (this.#logsToClearOnSuccess.has(id)) {
|
||||
this.#logsToClearOnSuccess.delete(id)
|
||||
|
||||
if (status === 'success') {
|
||||
return buf.set(id, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$root.updatedAt = Date.now()
|
||||
buf.set(id, $root)
|
||||
}
|
||||
|
||||
#onProgress = makeOnProgress({
|
||||
onRootTaskEnd: taskLog => {
|
||||
const { id } = taskLog
|
||||
this.#tasks.delete(id)
|
||||
},
|
||||
onTaskUpdate: taskLog => {
|
||||
this.#bufferTaskUpdate(taskLog)
|
||||
const buf = this.#onProgressBuffer
|
||||
|
||||
if (!this.#handleOnProgressScheduled) {
|
||||
this.#handleOnProgressScheduled = true
|
||||
|
||||
// task events are buffered, deduplicated and will be handled one after
|
||||
// the others on the next tick to avoid race conditions
|
||||
process.nextTick(this.#handleOnProgressBuffer)
|
||||
}
|
||||
|
||||
// Error objects are not JSON-ifiable by default
|
||||
const { result } = taskLog
|
||||
if (result instanceof Error && result.toJSON === undefined) {
|
||||
taskLog.result = serializeError(result)
|
||||
}
|
||||
|
||||
const { $root } = taskLog
|
||||
const { status, id } = $root
|
||||
if (status !== 'pending') {
|
||||
if (this.#logsToClearOnSuccess.has(id)) {
|
||||
this.#logsToClearOnSuccess.delete(id)
|
||||
|
||||
if (status === 'success') {
|
||||
return buf.set(id, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$root.updatedAt = Date.now()
|
||||
buf.set(id, $root)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -185,28 +181,6 @@ export default class Tasks extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an onProgress callback that feeds raw @vates/task events from an
|
||||
* external source (worker process, XO Proxy) into Tasks.mjs's store and
|
||||
* event emitter, making the task visible in the UI.
|
||||
*
|
||||
* Each call returns an independent makeOnProgress context (isolated taskLogs
|
||||
* Map), so concurrent backup jobs don't interfere with each other.
|
||||
*
|
||||
* Abort support for external tasks is not implemented — the external process
|
||||
* controls its own lifecycle.
|
||||
*/
|
||||
createExternalProgressHandler({ onRootTaskEnd, onRootTaskStart, onTaskUpdate } = {}) {
|
||||
return makeOnProgress({
|
||||
onRootTaskEnd,
|
||||
onRootTaskStart,
|
||||
onTaskUpdate: (taskLog, event) => {
|
||||
this.#bufferTaskUpdate(taskLog)
|
||||
onTaskUpdate?.(taskLog, event)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async abort(id, reason) {
|
||||
const task = this.#tasks.get(id)
|
||||
if (task === undefined) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@xen-orchestra/proxy",
|
||||
"version": "0.30.3",
|
||||
"version": "0.30.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "XO Proxy used to remotely execute backup jobs",
|
||||
"keywords": [
|
||||
@@ -33,13 +33,13 @@
|
||||
"@vates/disposable": "^0.1.6",
|
||||
"@vates/task": "^0.7.0",
|
||||
"@xen-orchestra/async-map": "^0.1.3",
|
||||
"@xen-orchestra/backups": "^0.73.2",
|
||||
"@xen-orchestra/fs": "^4.9.0",
|
||||
"@xen-orchestra/backups": "^0.72.1",
|
||||
"@xen-orchestra/fs": "^4.8.0",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"@xen-orchestra/mixin": "^0.2.1",
|
||||
"@xen-orchestra/mixins": "^0.20.0",
|
||||
"@xen-orchestra/self-signed": "^0.2.1",
|
||||
"@xen-orchestra/xapi": "^8.8.0",
|
||||
"@xen-orchestra/xapi": "^8.7.2",
|
||||
"ajv": "^8.0.3",
|
||||
"app-conf": "^3.0.0",
|
||||
"async-iterator-to-stream": "^1.1.0",
|
||||
|
||||
@@ -4,7 +4,7 @@ End-to-end QA test suite for Xen Orchestra. Tests core features (VM, backup, exp
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js >= 24 (required for `await using` and `--env-file-if-exists`)
|
||||
- Node.js >= 22 (required for `await using` and `--env-file-if-exists`)
|
||||
- A running Xen Orchestra instance (REST API + WebSocket)
|
||||
- A reference VM to clone for tests
|
||||
- An available Storage Repository
|
||||
@@ -30,26 +30,27 @@ cp .env.example .env
|
||||
|
||||
All variables are required. The test suite fails immediately if any are missing.
|
||||
|
||||
| Variable | Description | Example |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------- |
|
||||
| `HOSTNAME` | XO instance URL | `http://10.1.4.216:9000` |
|
||||
| `USERNAME` | XO user | `admin@admin.net` |
|
||||
| `PASSWORD` | XO password | `admin` |
|
||||
| `REFERENCE_VM_ID` | UUID of the VM to clone for tests | `61c24db9-262d-...` |
|
||||
| `SR_ID` | UUID of the Storage Repository | `8aa2fb4a-143e-...` |
|
||||
| `VM_PREFIX` | Test VM name prefix | `TST` |
|
||||
| `BACKUP_REPOSITORY_NAME` | Backup repository name in XO | `Test backup QA` |
|
||||
| `BACKUP_REPOSITORY_URL` | `@xen-orchestra/fs` URL for the test backup remote. For `file://` remotes the path must contain `test`, `qa`, or `tmp/xo`. Any supported backend works (`s3://`, `nfs://`, `azure://`, …). | `file:///tmp/xo-test-backups` |
|
||||
| `VHD_EXPORT_PATH` | VHD/XVA export directory (must contain `test`, `qa`, or `tmp/`) | `/tmp/xo-test-exports` |
|
||||
| Variable | Description | Example |
|
||||
| ------------------------ | -------------------------------------------------------------------------- | ------------------------ |
|
||||
| `HOSTNAME` | XO instance URL | `http://10.1.4.216:9000` |
|
||||
| `USERNAME` | XO user | `admin@admin.net` |
|
||||
| `PASSWORD` | XO password | `admin` |
|
||||
| `REFERENCE_VM_ID` | UUID of the VM to clone for tests | `61c24db9-262d-...` |
|
||||
| `SR_ID` | UUID of the Storage Repository | `8aa2fb4a-143e-...` |
|
||||
| `VM_PREFIX` | Test VM name prefix | `TST` |
|
||||
| `BACKUP_REPOSITORY_NAME` | Backup repository name in XO | `Test backup QA` |
|
||||
| `BACKUP_REPOSITORY_PATH` | Test backup path on the XO server (must contain `test`, `qa`, or `tmp/xo`) | `/tmp/xo-test-backups` |
|
||||
| `BACKUP_PATH` | Production backup path used for repository creation (not cleaned up) | `/var/lib/xo/backups` |
|
||||
| `VHD_EXPORT_PATH` | VHD/XVA export directory (must contain `test`, `qa`, or `tmp/`) | `/tmp/xo-test-exports` |
|
||||
|
||||
The following are required only for mirror backup tests (`qa:mirror`):
|
||||
The following are required only for mirror backup tests (`qa:backup:mirror`):
|
||||
|
||||
| Variable | Description | Example |
|
||||
| ------------------------------------ | -------------------------------------------------------------------------------------------------------- | ---------------------------- |
|
||||
| `MIRROR_DESTINATION_REPOSITORY_NAME` | Mirror destination repository name | `Test mirror QA` |
|
||||
| `MIRROR_DESTINATION_REPOSITORY_URL` | `@xen-orchestra/fs` URL for the mirror destination. Same backend flexibility as `BACKUP_REPOSITORY_URL`. | `file:///tmp/xo-test-mirror` |
|
||||
| Variable | Description | Example |
|
||||
| ------------------------------------ | ---------------------------------------------------------------- | --------------------- |
|
||||
| `MIRROR_DESTINATION_REPOSITORY_NAME` | Mirror destination repository name | `Test mirror QA` |
|
||||
| `MIRROR_DESTINATION_REPOSITORY_PATH` | Mirror destination path (must contain `test`, `qa`, or `tmp/xo`) | `/tmp/xo-test-mirror` |
|
||||
|
||||
> **Safety**: for `file://` remotes the path component of the URL must contain `test`, `qa`, or `tmp/xo` to prevent accidental deletion of production data. Non-local remotes (`s3://`, `nfs://`, …) skip this local-path check — cleanup is delegated to the XO API.
|
||||
> **Safety**: paths used for automatic cleanup must contain `test`, `qa`, or `tmp/xo` to prevent accidental deletion of production data.
|
||||
|
||||
## Logging
|
||||
|
||||
|
||||
@@ -12,14 +12,11 @@ VM_PREFIX=TST
|
||||
REFERENCE_VM_ID= # Required: UUID of the reference VM to clone for tests
|
||||
SR_ID= # Required: UUID of the Storage Repository for health checks
|
||||
BACKUP_REPOSITORY_NAME=Test backup QA
|
||||
POOL_ID= # Required for metadata backup tests: UUID of the pool to back up
|
||||
|
||||
# Test Backup URLs (@xen-orchestra/fs format)
|
||||
# IMPORTANT: Only test-scoped paths are allowed for automatic cleanup of file:// remotes.
|
||||
# The path component of the URL must contain 'test', 'qa', or 'tmp/xo'.
|
||||
# Any @xen-orchestra/fs-compatible URL is accepted (file://, s3://, nfs://, azure://, …)
|
||||
# use " around the url to handle # escaping
|
||||
BACKUP_REPOSITORY_URL="file:///tmp/xo-test-backups"
|
||||
# Test Backup Paths
|
||||
# IMPORTANT: Only test-scoped paths are allowed for automatic cleanup
|
||||
# Paths must contain 'test', 'qa', or 'tmp/xo' to prevent production data deletion
|
||||
BACKUP_REPOSITORY_PATH=/tmp/xo-test-backups
|
||||
|
||||
# ===========================================
|
||||
# MIRROR BACKUP TEST CONFIGURATION
|
||||
@@ -27,9 +24,8 @@ BACKUP_REPOSITORY_URL="file:///tmp/xo-test-backups"
|
||||
|
||||
# Destination repository for mirror backup tests
|
||||
# Source is BACKUP_REPOSITORY_NAME above
|
||||
# use " around the url to handle # escaping
|
||||
MIRROR_DESTINATION_REPOSITORY_NAME=Test mirror QA
|
||||
MIRROR_DESTINATION_REPOSITORY_URL="file:///tmp/xo-test-mirror"
|
||||
MIRROR_DESTINATION_REPOSITORY_PATH=/tmp/xo-test-mirror
|
||||
|
||||
# ===========================================
|
||||
# REPLICATION TEST CONFIGURATION
|
||||
@@ -46,12 +42,3 @@ REPLICATION_DESTINATION_SR_ID= # Required: UUID of the destination Storage Repo
|
||||
# Directory for VHD/XVA export files
|
||||
# Must be a test-scoped path (containing 'test', 'qa', or 'tmp/') for cleanup safety
|
||||
VHD_EXPORT_PATH=/tmp/xo-test-exports
|
||||
|
||||
# Gmail: enable 2-Step Verification then generate an App Password at
|
||||
# https://myaccount.google.com/apppasswords
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=youraddress@gmail.com
|
||||
SMTP_PASS=xxxx xxxx xxxx xxxx
|
||||
SMTP_FROM=youraddress@gmail.com
|
||||
SMTP_TO=ops@vates.tech
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../scripts/npmignore
|
||||
@@ -12,7 +12,7 @@ End-to-end QA test suite for Xen Orchestra. Tests core features (VM, backup, exp
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js >= 24 (required for `await using` and `--env-file-if-exists`)
|
||||
- Node.js >= 22 (required for `await using` and `--env-file-if-exists`)
|
||||
- A running Xen Orchestra instance (REST API + WebSocket)
|
||||
- A reference VM to clone for tests
|
||||
- An available Storage Repository
|
||||
@@ -38,26 +38,27 @@ cp .env.example .env
|
||||
|
||||
All variables are required. The test suite fails immediately if any are missing.
|
||||
|
||||
| Variable | Description | Example |
|
||||
| ------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
|
||||
| `HOSTNAME` | XO instance URL | `http://10.1.4.216:9000` |
|
||||
| `USERNAME` | XO user | `admin@admin.net` |
|
||||
| `PASSWORD` | XO password | `admin` |
|
||||
| `REFERENCE_VM_ID` | UUID of the VM to clone for tests | `61c24db9-262d-...` |
|
||||
| `SR_ID` | UUID of the Storage Repository | `8aa2fb4a-143e-...` |
|
||||
| `VM_PREFIX` | Test VM name prefix | `TST` |
|
||||
| `BACKUP_REPOSITORY_NAME` | Backup repository name in XO | `Test backup QA` |
|
||||
| `BACKUP_REPOSITORY_URL` | `@xen-orchestra/fs` URL for the test backup remote. For `file://` remotes the path must contain `test`, `qa`, or `tmp/xo`. Any supported backend works (`s3://`, `nfs://`, `azure://`, …). | `file:///tmp/xo-test-backups` |
|
||||
| `VHD_EXPORT_PATH` | VHD/XVA export directory (must contain `test`, `qa`, or `tmp/`) | `/tmp/xo-test-exports` |
|
||||
| Variable | Description | Example |
|
||||
| ------------------------ | -------------------------------------------------------------------------- | ------------------------ |
|
||||
| `HOSTNAME` | XO instance URL | `http://10.1.4.216:9000` |
|
||||
| `USERNAME` | XO user | `admin@admin.net` |
|
||||
| `PASSWORD` | XO password | `admin` |
|
||||
| `REFERENCE_VM_ID` | UUID of the VM to clone for tests | `61c24db9-262d-...` |
|
||||
| `SR_ID` | UUID of the Storage Repository | `8aa2fb4a-143e-...` |
|
||||
| `VM_PREFIX` | Test VM name prefix | `TST` |
|
||||
| `BACKUP_REPOSITORY_NAME` | Backup repository name in XO | `Test backup QA` |
|
||||
| `BACKUP_REPOSITORY_PATH` | Test backup path on the XO server (must contain `test`, `qa`, or `tmp/xo`) | `/tmp/xo-test-backups` |
|
||||
| `BACKUP_PATH` | Production backup path used for repository creation (not cleaned up) | `/var/lib/xo/backups` |
|
||||
| `VHD_EXPORT_PATH` | VHD/XVA export directory (must contain `test`, `qa`, or `tmp/`) | `/tmp/xo-test-exports` |
|
||||
|
||||
The following are required only for mirror backup tests (`qa:mirror`):
|
||||
The following are required only for mirror backup tests (`qa:backup:mirror`):
|
||||
|
||||
| Variable | Description | Example |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
|
||||
| `MIRROR_DESTINATION_REPOSITORY_NAME` | Mirror destination repository name | `Test mirror QA` |
|
||||
| `MIRROR_DESTINATION_REPOSITORY_URL` | `@xen-orchestra/fs` URL for the mirror destination. Same backend flexibility as `BACKUP_REPOSITORY_URL`. | `file:///tmp/xo-test-mirror` |
|
||||
| Variable | Description | Example |
|
||||
| ------------------------------------ | ---------------------------------------------------------------- | --------------------- |
|
||||
| `MIRROR_DESTINATION_REPOSITORY_NAME` | Mirror destination repository name | `Test mirror QA` |
|
||||
| `MIRROR_DESTINATION_REPOSITORY_PATH` | Mirror destination path (must contain `test`, `qa`, or `tmp/xo`) | `/tmp/xo-test-mirror` |
|
||||
|
||||
> **Safety**: for `file://` remotes the path component of the URL must contain `test`, `qa`, or `tmp/xo` to prevent accidental deletion of production data. Non-local remotes (`s3://`, `nfs://`, …) skip this local-path check — cleanup is delegated to the XO API.
|
||||
> **Safety**: paths used for automatic cleanup must contain `test`, `qa`, or `tmp/xo` to prevent accidental deletion of production data.
|
||||
|
||||
## Logging
|
||||
|
||||
|
||||
@@ -7,41 +7,29 @@ import { BACKUP_JOB_NAME_PREFIX, getRequiredEnv } from '../utils/index.js'
|
||||
const log = createLogger('cleanup')
|
||||
|
||||
/**
|
||||
* Allowed paths for automatic backup cleanup of file:// remotes.
|
||||
* Allowed paths for automatic backup cleanup.
|
||||
* SECURITY: Only test-scoped paths containing 'test', 'qa', or 'tmp/xo'.
|
||||
* Non-file:// remotes return an empty list (cleanup is delegated to XO API).
|
||||
*
|
||||
* Computed lazily so that `process.env.BACKUP_REPOSITORY_URL` is read at call
|
||||
* Computed lazily so that `process.env.BACKUP_REPOSITORY_PATH` is read at call
|
||||
* time rather than module-load time — otherwise ESM import hoisting would make
|
||||
* it evaluate before any in-code env loading runs.
|
||||
*
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
const getAllowedCleanupPaths = () => {
|
||||
const repoUrl = process.env.BACKUP_REPOSITORY_URL
|
||||
|
||||
// Non-local remotes have no local path to safety-check
|
||||
if (!repoUrl?.startsWith('file://')) return []
|
||||
|
||||
let repoPath
|
||||
try {
|
||||
repoPath = new URL(repoUrl).pathname
|
||||
} catch {
|
||||
log.warn('Rejected cleanup URL: invalid URL', { repoUrl })
|
||||
return []
|
||||
}
|
||||
const repoPath = getRequiredEnv('BACKUP_REPOSITORY_PATH')
|
||||
|
||||
// SECURITY: Reject paths containing path traversal sequences
|
||||
if (repoPath?.includes('..')) {
|
||||
if (repoPath.includes('..')) {
|
||||
log.warn('Rejected cleanup path: contains path traversal', { repoPath })
|
||||
return []
|
||||
}
|
||||
|
||||
const normalized = repoPath ? path.resolve(repoPath).toLowerCase() : ''
|
||||
const normalized = path.resolve(repoPath).toLowerCase()
|
||||
const isTestPath = ['test', 'qa', 'tmp/xo'].some(marker => normalized.includes(marker))
|
||||
|
||||
if (!isTestPath) {
|
||||
if (repoPath) log.warn('Rejected cleanup path: not a test path', { repoPath })
|
||||
log.warn('Rejected cleanup path: not a test path', { repoPath })
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -223,19 +211,10 @@ export class CleanupClient {
|
||||
}
|
||||
|
||||
const deleteVM = async vm => {
|
||||
if (vm.power_state === 'Running') {
|
||||
try {
|
||||
await this.dispatchClient.vm.stop(vm.uuid, { force: true })
|
||||
await new Promise(resolve => setTimeout(resolve, 3_000))
|
||||
} catch (error) {
|
||||
log.warn('Could not stop VM before deletion', { uuid: vm.uuid, error })
|
||||
}
|
||||
}
|
||||
|
||||
await this.dispatchClient.vm.delete(vm.uuid, {
|
||||
deleteDisks: true,
|
||||
force: true,
|
||||
forceBlockedOperation: true,
|
||||
force: config.force,
|
||||
forceBlockedOperation: config.force,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -291,7 +270,7 @@ export class CleanupClient {
|
||||
try {
|
||||
await this.dispatchClient.backup.deleteVmBackups(backupIds.slice(i, i + 10))
|
||||
} catch (batchError) {
|
||||
log.warn('Delete VM backups failed', { error: batchError })
|
||||
log.warn('Failed to delete backup batch', { error: batchError.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,30 +280,11 @@ export class CleanupClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror backup jobs require mirrorBackup.deleteJob, standard jobs use backupNg.deleteJob.
|
||||
// The REST API may not expose the type field for mirror jobs, so fall back when backupNg
|
||||
// returns "no such job" (error.data.type === 'job').
|
||||
// Mirror backup jobs require mirrorBackup.deleteJob, standard jobs use backupNg.deleteJob
|
||||
if (job.type === 'mirrorBackup') {
|
||||
await this.dispatchClient.backup.deleteMirrorBackupJob(job.id)
|
||||
} else {
|
||||
try {
|
||||
await this.dispatchClient.backup.deleteBackupJob(job.id)
|
||||
} catch (error) {
|
||||
if (error?.data?.type !== 'job') {
|
||||
throw error
|
||||
}
|
||||
// backupNg doesn't know this job — may be a mirror backup job; try the mirror method
|
||||
try {
|
||||
await this.dispatchClient.backup.deleteMirrorBackupJob(job.id)
|
||||
} catch (mirrorError) {
|
||||
if (mirrorError?.data?.type === 'job') {
|
||||
// Job is unreachable through both RPCs (orphaned from a previous run) — skip silently
|
||||
log.debug('Backup job not found via any delete method, skipping', { jobId: job.id })
|
||||
return
|
||||
}
|
||||
throw mirrorError
|
||||
}
|
||||
}
|
||||
await this.dispatchClient.backup.deleteBackupJob(job.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -429,13 +429,11 @@ export class AbstractRequest {
|
||||
duration,
|
||||
}
|
||||
} catch (error) {
|
||||
// Clean up partial file on error (ignore ENOENT — file was never created if the HTTP request failed)
|
||||
// Clean up partial file on error
|
||||
try {
|
||||
await fs.unlink(outputPath)
|
||||
} catch (cleanupError) {
|
||||
if (cleanupError.code !== 'ENOENT') {
|
||||
log.warn('Cleanup failed for partial download', { outputPath, error: cleanupError.message })
|
||||
}
|
||||
log.warn('Cleanup failed for partial download', { outputPath, error: cleanupError.message })
|
||||
}
|
||||
|
||||
if (error.code) {
|
||||
|
||||
@@ -100,10 +100,7 @@ export class BackupRequest extends AbstractRequest {
|
||||
const result = await this.dispatchClient.xoClient.call('backupNg.deleteJob', { id: jobId })
|
||||
return result
|
||||
} catch (error) {
|
||||
// "no such job" means the caller used the wrong delete method (e.g. mirror job) — let it handle the fallback
|
||||
if (error?.data?.type !== 'job') {
|
||||
log.warn('Delete backup job failed', { error })
|
||||
}
|
||||
log.warn('Delete backup job failed', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -232,9 +229,7 @@ export class BackupRequest extends AbstractRequest {
|
||||
const result = await this.dispatchClient.xoClient.call('mirrorBackup.deleteJob', { id: jobId })
|
||||
return result
|
||||
} catch (error) {
|
||||
if (error?.data?.type !== 'job') {
|
||||
log.warn('Delete mirror backup job failed', { error })
|
||||
}
|
||||
log.warn('Delete mirror backup job failed', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -292,168 +287,6 @@ export class BackupRequest extends AbstractRequest {
|
||||
return backupLog
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Metadata Backup operations (metadataBackup.* API)
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Creates a new metadata backup job in XenOrchestra.
|
||||
*
|
||||
* Accepts a caller-friendly shape:
|
||||
* { name, pools: { [poolId]: true }, remotes: { [remoteId]: true },
|
||||
* schedules, settings, xoMetadata }
|
||||
* and converts pools/remotes to the { id } pattern expected by the API.
|
||||
*
|
||||
* @param {Object} config - Metadata backup job configuration
|
||||
* @returns {Promise<string>} The created job ID
|
||||
*/
|
||||
async createMetadataBackupJob(config) {
|
||||
this._ensureConnected()
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error('Valid metadata backup configuration object is required')
|
||||
}
|
||||
|
||||
const { pools, remotes, ...rest } = config
|
||||
|
||||
const poolId = pools && typeof pools === 'object' ? Object.keys(pools)[0] : undefined
|
||||
const remoteId = remotes && typeof remotes === 'object' ? Object.keys(remotes)[0] : undefined
|
||||
|
||||
const xoConfig = { ...rest }
|
||||
if (poolId !== undefined) xoConfig.pools = { id: poolId }
|
||||
if (remoteId !== undefined) xoConfig.remotes = { id: remoteId }
|
||||
|
||||
try {
|
||||
const result = await this.dispatchClient.xoClient.call('metadataBackup.createJob', xoConfig)
|
||||
// metadataBackup.createJob returns the full job object (unlike backupNg.createJob which returns only the ID)
|
||||
return result?.id ?? result
|
||||
} catch (error) {
|
||||
log.warn('Metadata backup job creation failed', { message: error.message, code: error.code, data: error.data })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a metadata backup job by ID.
|
||||
*
|
||||
* Uses the WebSocket API because the backup-jobs REST endpoint only covers VM backup jobs.
|
||||
*
|
||||
* @param {string} jobId - Metadata backup job ID
|
||||
* @returns {Promise<Object>} The job object
|
||||
*/
|
||||
async getMetadataJob(jobId) {
|
||||
this._ensureConnected()
|
||||
|
||||
if (!jobId || typeof jobId !== 'string') {
|
||||
throw new Error('Valid job ID is required')
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.dispatchClient.xoClient.call('metadataBackup.getJob', { id: jobId })
|
||||
} catch (error) {
|
||||
log.warn('Get metadata backup job failed', { jobId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a metadata backup job.
|
||||
*
|
||||
* @param {string} jobId - Metadata backup job ID to delete
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deleteMetadataBackupJob(jobId) {
|
||||
this._ensureConnected()
|
||||
|
||||
if (!jobId || typeof jobId !== 'string') {
|
||||
throw new Error('Valid job ID is required')
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.dispatchClient.xoClient.call('metadataBackup.deleteJob', { id: jobId })
|
||||
} catch (error) {
|
||||
log.warn('Delete metadata backup job failed', { jobId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a metadata backup job and monitors completion.
|
||||
*
|
||||
* Same polling pattern as runJobAndGetLog, but calls metadataBackup.runJob.
|
||||
*
|
||||
* @param {string} jobId - Metadata backup job ID to execute
|
||||
* @param {string} scheduleId - Schedule ID to use for this execution
|
||||
* @returns {Promise<Object>} Complete backup log with status and details
|
||||
*/
|
||||
async runMetadataJobAndGetLog(jobId, scheduleId) {
|
||||
this._ensureConnected()
|
||||
|
||||
log.debug('Running metadata backup job', { jobId, scheduleId })
|
||||
|
||||
const runStartTime = Date.now() - 5000
|
||||
|
||||
try {
|
||||
await this.dispatchClient.xoClient.call('metadataBackup.runJob', {
|
||||
id: jobId,
|
||||
schedule: scheduleId,
|
||||
})
|
||||
} catch (wsError) {
|
||||
// The server has a known bug where a post-job cleanup error of `undefined` causes
|
||||
// serializeError() to throw, returning a -32000 WS error even though the backup
|
||||
// already completed. Continue polling for the log rather than failing immediately.
|
||||
log.debug('metadataBackup.runJob returned a WS error, polling for backup log anyway', {
|
||||
jobId,
|
||||
code: wsError.code,
|
||||
error: wsError.message,
|
||||
})
|
||||
}
|
||||
|
||||
const backupLog = await waitUntil(
|
||||
async () => {
|
||||
try {
|
||||
const latestLog = await this.dispatchClient.backupLog.getLatestForJob(jobId, runStartTime)
|
||||
if (!latestLog) return false
|
||||
const status = latestLog.status?.toLowerCase()
|
||||
if (status === 'success' || status === 'failure' || latestLog.end) {
|
||||
return latestLog
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
log.debug('Waiting for metadata backup completion', { error })
|
||||
return false
|
||||
}
|
||||
},
|
||||
2000,
|
||||
300_000,
|
||||
{ exponentialBackoff: true, backoffMultiplier: 1.2, maxInterval: 10_000 }
|
||||
)
|
||||
|
||||
log.debug('Metadata backup completed', { status: backupLog.status })
|
||||
return backupLog
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists metadata backups for the given backup repositories.
|
||||
*
|
||||
* @param {string[]} remoteIds - Array of backup repository IDs
|
||||
* @returns {Promise<{xo: Object, pool: Object}>} Nested metadata backup structure
|
||||
*/
|
||||
async listMetadataBackups(remoteIds) {
|
||||
this._ensureConnected()
|
||||
|
||||
if (!Array.isArray(remoteIds) || remoteIds.length === 0) {
|
||||
throw new Error('Valid array of remote IDs is required')
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.dispatchClient.xoClient.call('metadataBackup.list', { remotes: remoteIds })
|
||||
} catch (error) {
|
||||
log.warn('List metadata backups failed', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Standard Backup operations (backupNg.* API)
|
||||
// ===========================================================================
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user