mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-11 09:50:46 -06:00
Compare commits
1 Commits
debug_time
...
docs-fixes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1857691a5 |
37
.eslintrc.js
37
.eslintrc.js
@@ -233,29 +233,7 @@ module.exports = {
|
||||
'@intlify/vue-i18n/no-duplicate-keys-in-locale': 'error',
|
||||
'@intlify/vue-i18n/no-missing-keys-in-other-locales': [
|
||||
'error',
|
||||
{
|
||||
ignoreLocales: [
|
||||
'de',
|
||||
'fa',
|
||||
'cs',
|
||||
'es',
|
||||
'sv',
|
||||
'it',
|
||||
'ru',
|
||||
'uk',
|
||||
'nl',
|
||||
'pt-BR',
|
||||
'da',
|
||||
'ko',
|
||||
'nb-NO',
|
||||
'ja',
|
||||
'fi',
|
||||
'pl',
|
||||
'zh-Hans',
|
||||
'sk',
|
||||
'pt',
|
||||
],
|
||||
},
|
||||
{ ignoreLocales: ['de', 'fa', 'cs', 'es', 'sv', 'it', 'ru', 'uk', 'nl'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -281,7 +259,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['@xen-orchestra/{web-core,lite,web}/src/route-map.d.ts'],
|
||||
files: ['@xen-orchestra/{web-core,lite,web}/typed-router.d.ts'],
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
},
|
||||
@@ -290,17 +268,6 @@ module.exports = {
|
||||
'eslint-comments/no-unlimited-disable': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.d.ts'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'n/no-unsupported-features/es-syntax': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
ignorePatterns: ['@vates/fatfs/'],
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
- name: Setup Node environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '>=22.12'
|
||||
node-version: '20'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install project dependencies
|
||||
|
||||
@@ -3,5 +3,4 @@
|
||||
**/*.hbs
|
||||
|
||||
# Generated files
|
||||
/@xen-orchestra/web/src/route-map.d.ts
|
||||
/@xen-orchestra/lite/src/route-map.d.ts
|
||||
/@xen-orchestra/web/typed-router.d.ts
|
||||
|
||||
5
@vates/async-each/index.d.ts
vendored
5
@vates/async-each/index.d.ts
vendored
@@ -1,5 +0,0 @@
|
||||
export function asyncEach<Item>(
|
||||
iterable: Iterable<Item> | AsyncIterable<Item>,
|
||||
iteratee: (item: Item, index: number, iterable: Iterable<Item> | AsyncIterable<Item>) => Promise<void>,
|
||||
opts?: { concurrency?: number; signal?: AbortSignal; stopOnError?: boolean }
|
||||
): Promise<void>
|
||||
@@ -73,20 +73,20 @@ exports.asyncEach = function asyncEach(iterable, iteratee, { concurrency = 10, s
|
||||
}
|
||||
nextIsRunning = true
|
||||
if (running < concurrency) {
|
||||
try {
|
||||
const cursor = await it.next()
|
||||
if (cursor.done) {
|
||||
next = () => {
|
||||
if (running === 0) {
|
||||
if (errors.length === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new AggregateError(errors))
|
||||
}
|
||||
const cursor = await it.next()
|
||||
if (cursor.done) {
|
||||
next = () => {
|
||||
if (running === 0) {
|
||||
if (errors.length === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new AggregateError(errors))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
++running
|
||||
}
|
||||
} else {
|
||||
++running
|
||||
try {
|
||||
const result = iteratee.call(this, cursor.value, index++, iterable)
|
||||
let then
|
||||
if (result != null && typeof result === 'object' && typeof (then = result.then) === 'function') {
|
||||
@@ -94,9 +94,9 @@ exports.asyncEach = function asyncEach(iterable, iteratee, { concurrency = 10, s
|
||||
} else {
|
||||
onFulfilled(result)
|
||||
}
|
||||
} catch (error) {
|
||||
onRejected(error)
|
||||
}
|
||||
} catch (error) {
|
||||
onRejected(error)
|
||||
}
|
||||
nextIsRunning = false
|
||||
return next()
|
||||
|
||||
@@ -67,17 +67,20 @@ describe('asyncEach', () => {
|
||||
})
|
||||
|
||||
it('stops on first error when stopOnError is true', async () => {
|
||||
const tracker = new assert.CallTracker()
|
||||
|
||||
const error = new Error()
|
||||
const iteratee = spy((_, i) => {
|
||||
const iteratee = tracker.calls((_, i) => {
|
||||
if (i === 1) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}, 2)
|
||||
assert.deepStrictEqual(
|
||||
await rejectionOf(asyncEach(iterable, iteratee, { concurrency: 1, stopOnError: true })),
|
||||
error
|
||||
)
|
||||
assert.deepStrictEqual(iteratee.callCount, 2)
|
||||
|
||||
tracker.verify()
|
||||
})
|
||||
|
||||
it('rejects AggregateError when stopOnError is false', async () => {
|
||||
@@ -97,30 +100,19 @@ describe('asyncEach', () => {
|
||||
})
|
||||
|
||||
it('can be interrupted with an AbortSignal', async () => {
|
||||
const tracker = new assert.CallTracker()
|
||||
|
||||
const ac = new AbortController()
|
||||
const iteratee = spy((_, i) => {
|
||||
const iteratee = tracker.calls((_, i) => {
|
||||
if (i === 1) {
|
||||
ac.abort()
|
||||
}
|
||||
})
|
||||
}, 2)
|
||||
await assert.rejects(asyncEach(iterable, iteratee, { concurrency: 1, signal: ac.signal }), {
|
||||
message: 'asyncEach aborted',
|
||||
})
|
||||
assert.deepStrictEqual(iteratee.callCount, 2)
|
||||
})
|
||||
|
||||
it('handle error on iterable', async () => {
|
||||
const iteratee = spy(async () => {})
|
||||
const iterable = async function* () {
|
||||
for (const value of [1, 2, 3]) {
|
||||
if (value === 2) {
|
||||
throw new Error('iterator error')
|
||||
}
|
||||
await randomDelay()
|
||||
yield value
|
||||
}
|
||||
}
|
||||
await assert.rejects(() => asyncEach(iterable(), iteratee, { concurrency: 1 }), { message: 'iterator error' })
|
||||
tracker.verify()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -24,8 +24,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"types": "index.d.ts",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/multi-key-map": "^0.2.0",
|
||||
"@xen-orchestra/async-map": "^0.1.3",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"ensure-array": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -62,7 +62,7 @@ exports.parseFlags = function (flags) {
|
||||
case 'ax': info = {read:false, write:true, create:true, append:true, exclusive:true}; break;
|
||||
case 'a+': info = {read:true, write:true, create:true, append:true}; break;
|
||||
case 'ax+': info = {read:true, write:true, create:true, append:true, exclusive:true}; break;
|
||||
default: throw Error("Unknown mode: "+flags); // TODO: throw as `S.err.INVAL`
|
||||
default: throw Error("Uknown mode: "+flags); // TODO: throw as `S.err.INVAL`
|
||||
}
|
||||
if (info.sync) throw Error("Mode not implemented."); // TODO: what would this require of us?
|
||||
if (_dir) info._openDir = true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@vates/fatfs",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.0",
|
||||
"description": "fs implementation on top of raw FAT16/FAT32 block source",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -90,7 +90,7 @@ function startTests(vol, waitTime) {
|
||||
assert(path === file, "Fixed fluffy path matches normal one.");
|
||||
});
|
||||
fs.realpath([BASE_DIR,"non","existent","path"].join('/'), function (e) {
|
||||
assert(e, "Expected error calling fs.realpath on nonexistent file.");
|
||||
assert(e, "Expected error calling fs.realpath on non-existent file.");
|
||||
});
|
||||
|
||||
fs.readdir(BASE_DIR, function (e, arr) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import Fuse from 'fuse-native'
|
||||
import LRU from 'lru-cache'
|
||||
import Fuse from 'fuse-native'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { VhdSynthetic } from 'vhd-lib'
|
||||
import { Disposable, fromCallback } from 'promise-toolbox'
|
||||
|
||||
const { warn } = createLogger('vates:fuse-vhd')
|
||||
|
||||
// build a stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
|
||||
// build a s stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
|
||||
const stat = st => ({
|
||||
mtime: st.mtime || new Date(),
|
||||
atime: st.atime || new Date(),
|
||||
@@ -20,109 +20,42 @@ const stat = st => ({
|
||||
export const mount = Disposable.factory(async function* mount(handler, diskPath, mountDir) {
|
||||
const vhd = yield VhdSynthetic.fromVhdChain(handler, diskPath)
|
||||
|
||||
const cache = new LRU({
|
||||
max: 16, // each cached block is 2MB in size
|
||||
})
|
||||
await vhd.readBlockAllocationTable()
|
||||
const { blockSize } = vhd.header
|
||||
|
||||
// A zeroed buffer returned for VHD blocks that are not present in the chain.
|
||||
// Never mutated — safe to share across all reads.
|
||||
const EMPTY_BLOCK = Buffer.alloc(blockSize, 0)
|
||||
|
||||
// --- coalescing block read queue ---
|
||||
//
|
||||
// FUSE read requests from NTFS-3g arrive in small chunks (typically 4 KiB)
|
||||
// while VHD blocks are 2 MiB. Reading blocks one at a time from the backing
|
||||
// store (CIFS/NFS/…) bounds libuv threadpool usage to 1 concurrent I/O call.
|
||||
//
|
||||
// Coalescing: all pending requests for the same blockId share a single
|
||||
// readBlock() call. When the block resolves, every waiter is fulfilled at once.
|
||||
//
|
||||
// Together these two properties prevent the FUSE-on-FUSE threadpool deadlock:
|
||||
// concurrent NTFS-3g reads no longer starve the VHD block reads they depend on.
|
||||
// In-memory block cache. Each VHD block is 2 MiB; 16 blocks = 32 MiB.
|
||||
// This is the primary tool against the 300× CIFS amplification: NTFS-3g
|
||||
// reads a 2 MiB block in ~512 sequential 4 KiB FUSE requests. Without a
|
||||
// cache, each request causes a full 2 MiB CIFS read. With the cache, only
|
||||
// the first request reads from CIFS; the rest are served from RAM.
|
||||
const blockCache = new LRU({ max: 64 })
|
||||
|
||||
const pendingBlocks = new Map() // blockId → Promise<Buffer>
|
||||
const readQueue = [] // { blockId, resolve, reject }[]
|
||||
let queueRunning = false
|
||||
|
||||
// log stats every 10 s while there is activity
|
||||
|
||||
function enqueueBlock(blockId) {
|
||||
// 1. serve from cache if available
|
||||
if (blockCache.has(blockId)) {
|
||||
return Promise.resolve(blockCache.get(blockId))
|
||||
}
|
||||
// 2. coalesce: join an existing in-flight read for this block
|
||||
if (pendingBlocks.has(blockId)) {
|
||||
return pendingBlocks.get(blockId)
|
||||
}
|
||||
const p = new Promise((resolve, reject) => {
|
||||
readQueue.push({ blockId, resolve, reject })
|
||||
})
|
||||
pendingBlocks.set(blockId, p)
|
||||
p.then(
|
||||
data => {
|
||||
pendingBlocks.delete(blockId)
|
||||
blockCache.set(blockId, data)
|
||||
},
|
||||
() => pendingBlocks.delete(blockId)
|
||||
)
|
||||
drainQueue()
|
||||
return p
|
||||
}
|
||||
|
||||
function drainQueue() {
|
||||
if (queueRunning) return
|
||||
queueRunning = true
|
||||
// async IIFE — errors are forwarded to individual reject() callbacks,
|
||||
// so the outer floating promise is always fulfilled
|
||||
;(async () => {
|
||||
while (readQueue.length > 0) {
|
||||
const { blockId, resolve, reject } = readQueue.shift()
|
||||
try {
|
||||
resolve((await vhd.readBlock(blockId)).data)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
}
|
||||
queueRunning = false
|
||||
})()
|
||||
}
|
||||
|
||||
async function readData(buf, len, pos) {
|
||||
if (len === 0) return 0
|
||||
const startBlockId = Math.floor(pos / blockSize)
|
||||
// use (pos + len - 1) so an exact block boundary doesn't include the next block
|
||||
const endBlockId = Math.floor((pos + len - 1) / blockSize)
|
||||
|
||||
let copied = 0
|
||||
for (let blockId = startBlockId; blockId <= endBlockId; blockId++) {
|
||||
const data = vhd.containsBlock(blockId) ? await enqueueBlock(blockId) : EMPTY_BLOCK
|
||||
const offsetStart = blockId === startBlockId ? pos % blockSize : 0
|
||||
const offsetEnd = blockId === endBlockId ? ((pos + len - 1) % blockSize) + 1 : blockSize
|
||||
data.copy(buf, copied, offsetStart, offsetEnd)
|
||||
copied += offsetEnd - offsetStart
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
const fuse = new Fuse(mountDir, {
|
||||
async readdir(path, cb) {
|
||||
if (path === '/') return cb(null, ['vhd0'])
|
||||
if (path === '/') {
|
||||
return cb(null, ['vhd0'])
|
||||
}
|
||||
cb(Fuse.ENOENT)
|
||||
},
|
||||
async getattr(path, cb) {
|
||||
if (path === '/') return cb(null, stat({ mode: 'dir', size: 4096 }))
|
||||
if (path === '/vhd0') return cb(null, stat({ mode: 'file', size: vhd.footer.currentSize }))
|
||||
if (path === '/') {
|
||||
return cb(
|
||||
null,
|
||||
stat({
|
||||
mode: 'dir',
|
||||
size: 4096,
|
||||
})
|
||||
)
|
||||
}
|
||||
if (path === '/vhd0') {
|
||||
return cb(
|
||||
null,
|
||||
stat({
|
||||
mode: 'file',
|
||||
size: vhd.footer.currentSize,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
cb(Fuse.ENOENT)
|
||||
},
|
||||
read(path, fd, buf, len, pos, cb) {
|
||||
if (path === '/vhd0') {
|
||||
return readData(buf, len, pos).then(cb, error => {
|
||||
return vhd.readRawData(pos, len, cache, buf).then(cb, error => {
|
||||
warn('read error', { path, len, pos, error })
|
||||
cb(Fuse.EIO)
|
||||
})
|
||||
@@ -131,9 +64,7 @@ export const mount = Disposable.factory(async function* mount(handler, diskPath,
|
||||
},
|
||||
})
|
||||
return new Disposable(
|
||||
() => {
|
||||
return fromCallback(cb => fuse.unmount(cb))
|
||||
},
|
||||
() => fromCallback(cb => fuse.unmount(cb)),
|
||||
fromCallback(cb => fuse.mount(cb))
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vates/fuse-vhd",
|
||||
"version": "2.1.3",
|
||||
"version": "2.1.2",
|
||||
"license": "ISC",
|
||||
"private": false,
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fuse-vhd",
|
||||
@@ -19,12 +19,12 @@
|
||||
},
|
||||
"main": "./index.mjs",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/fs": "^4.9.3",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"@xen-orchestra/fs": "^4.6.2",
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"fuse-native": "^2.2.6",
|
||||
"lru-cache": "^7.14.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"vhd-lib": "^4.16.1"
|
||||
"vhd-lib": "^4.14.0"
|
||||
},
|
||||
"bin": {
|
||||
"xo-fuse-vhd": "./cli.mjs"
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@vates/generator-toolbox",
|
||||
"version": "1.1.3",
|
||||
"version": "1.0.4",
|
||||
"main": "dist/index.mjs",
|
||||
"license": "MIT",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"@types/node": "^20.6",
|
||||
"typescript": "~5.6",
|
||||
"typescript-eslint": "^8.61.0"
|
||||
"typescript-eslint": "^8.23.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
@@ -144,7 +144,14 @@ class Forked<T, TReturn, TNext> implements AsyncGenerator<T, TReturn, TNext> {
|
||||
return this.#parent.remove(this.#uid, e)
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncGenerator<T, TReturn, TNext> {
|
||||
return this
|
||||
async *[Symbol.asyncIterator](): AsyncGenerator<T> {
|
||||
while (true) {
|
||||
const res = await this.next()
|
||||
if (res.done) {
|
||||
break
|
||||
}
|
||||
|
||||
yield res.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import assert from 'node:assert'
|
||||
import { suite, test } from 'node:test'
|
||||
import { Synchronized } from './synchronized.mjs'
|
||||
|
||||
async function* makeRangeGenerator(end = Infinity, progress = { yielded: 0 }, onYielded = (_val: unknown) => {}) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function* makeRangeGenerator(end = Infinity, progress = { yielded: 0 }, onYielded = (val: unknown) => {}) {
|
||||
for (let i = 0; i < end; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
yield i
|
||||
@@ -16,7 +17,7 @@ async function consume(
|
||||
iterable: AsyncGenerator,
|
||||
delay = 2500,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onConsumed = (_val: unknown, _iterable: AsyncGenerator) => Promise.resolve(false)
|
||||
onConsumed = (val: unknown, iterable: AsyncGenerator) => Promise.resolve(false)
|
||||
) {
|
||||
for await (const val of iterable) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
@@ -27,30 +28,6 @@ async function consume(
|
||||
}
|
||||
|
||||
suite('success', () => {
|
||||
test('for-await-of break stops the source generator', async () => {
|
||||
let sourceClosed = false
|
||||
const source = (async function* () {
|
||||
try {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
yield i
|
||||
}
|
||||
} finally {
|
||||
sourceClosed = true
|
||||
}
|
||||
})()
|
||||
|
||||
const forker = new Synchronized(source)
|
||||
const fork = forker.fork('first')
|
||||
|
||||
// for-await-of must call fork.return() on break, which propagates to the source
|
||||
for await (const val of fork) {
|
||||
if (val === 2) break
|
||||
}
|
||||
|
||||
assert.strictEqual(sourceClosed, true)
|
||||
})
|
||||
|
||||
test('if works with multiple consumer', async () => {
|
||||
const progress = { yielded: 0 }
|
||||
const generator = makeRangeGenerator(3, progress)
|
||||
|
||||
@@ -19,7 +19,7 @@ export class Throttle {
|
||||
} else {
|
||||
speed = this.#bytesPerSecond
|
||||
}
|
||||
assert.ok(speed >= 0, `speed must be positive, ${speed} computed`)
|
||||
assert.ok(speed > 0, `speed must be greater than zero, ${speed} computed`)
|
||||
return speed
|
||||
}
|
||||
constructor(speed: number | (() => number)) {
|
||||
@@ -29,9 +29,7 @@ export class Throttle {
|
||||
getNextSlot(length: number): { timeout?: ReturnType<typeof setTimeout>; promise?: Promise<unknown> } {
|
||||
assert.notStrictEqual(length, undefined, `throttled stream need to expose a length property }`)
|
||||
assert.ok(length > 0, `throttled stream must expose a positive length property , ${length} given }`)
|
||||
if (this.speed === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const previous = this.#previousSlot
|
||||
const nextSlot = Math.round(previous + (length * 1000) / this.speed)
|
||||
if (nextSlot < Date.now()) {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
const TIMEOUT = Symbol('timeout')
|
||||
|
||||
export class Timeout<T> implements AsyncGenerator {
|
||||
#source: AsyncGenerator<T>
|
||||
#timeout: number
|
||||
@@ -11,30 +8,21 @@ export class Timeout<T> implements AsyncGenerator {
|
||||
this.#timeout = timeout
|
||||
}
|
||||
async next(): Promise<IteratorResult<T>> {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout>
|
||||
|
||||
const sourceNext = this.#source.next()
|
||||
const result = await Promise.race([
|
||||
sourceNext.then(res => {
|
||||
clearTimeout(timeoutHandle)
|
||||
return res
|
||||
}),
|
||||
new Promise<typeof TIMEOUT>(resolve => {
|
||||
timeoutHandle = setTimeout(() => resolve(TIMEOUT), this.#timeout)
|
||||
}),
|
||||
])
|
||||
|
||||
if (result === TIMEOUT) {
|
||||
// stop the source once the in-flight next settles to avoid data loss on the next call
|
||||
// we don't want to await for the end of timeouted code
|
||||
sourceNext.then(
|
||||
() => this.#source.return(undefined).catch(() => {}),
|
||||
() => {} // source already errored, nothing to clean up
|
||||
)
|
||||
throw new Error('Timeout reached')
|
||||
}
|
||||
|
||||
return result
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
const promiseTimeout = new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error('Timeout reached '))
|
||||
}, this.#timeout)
|
||||
})
|
||||
const promiseNext = new Promise((resolve, reject) => {
|
||||
this.#source.next().then(res => {
|
||||
// ensure timetout won't fire later
|
||||
clearTimeout(timeout)
|
||||
resolve(res)
|
||||
}, reject)
|
||||
})
|
||||
// promiseTimeout will never resolve
|
||||
return Promise.race([promiseNext, promiseTimeout]) as Promise<IteratorResult<T>>
|
||||
}
|
||||
return(): Promise<IteratorResult<T>> {
|
||||
return this.#source.return(undefined)
|
||||
|
||||
@@ -64,24 +64,4 @@ describe('Timeout class', () => {
|
||||
|
||||
await assert.rejects(timeout.throw(new Error('Test error')), 'Expected error was not thrown')
|
||||
})
|
||||
|
||||
it('should close the source generator after a timeout', async () => {
|
||||
let sourceClosed = false
|
||||
const slowSource = (async function* () {
|
||||
try {
|
||||
// resolves after 100ms so the in-flight next() eventually settles
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
yield 1
|
||||
} finally {
|
||||
sourceClosed = true
|
||||
}
|
||||
})()
|
||||
|
||||
const timeout = new Timeout(slowSource, 10) // fires before source yields
|
||||
await assert.rejects(() => timeout.next(), /Timeout reached/)
|
||||
|
||||
// wait for the in-flight next() to settle so source.return() can be called
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
assert.strictEqual(sourceClosed, true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"compilerOptions": {
|
||||
"target": "es2023",
|
||||
"module": "ESNext",
|
||||
"types": ["node"], // Only use types from Node.js and packages that are explicitly imported
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"declaration": true, // Generates `.d.ts` files for type safety
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
### `create([opts], [requestListener])`
|
||||
|
||||
Creates a new server instance. `opts` may contain `createServer` and `createSecureServer` factories (defaults to `http.createServer` and `https.createServer`). `requestListener` is registered as a `request` event listener.
|
||||
|
||||
```js
|
||||
import { create } from '@vates/http-server-plus'
|
||||
|
||||
const server = create(function onRequest(req, res) {
|
||||
res.end('Hello')
|
||||
})
|
||||
```
|
||||
|
||||
### `server.listen(opts)`
|
||||
|
||||
Starts listening. Returns a promise that resolves to a human-readable address string, or rejects with `error.niceAddress` set.
|
||||
|
||||
```js
|
||||
// HTTP on a specific port
|
||||
await server.listen({ port: 8080 })
|
||||
|
||||
// HTTPS — detected automatically when cert+key are provided
|
||||
await server.listen({
|
||||
port: 443,
|
||||
cert: fs.readFileSync('certificate.pem'),
|
||||
key: fs.readFileSync('private_key.pem'),
|
||||
})
|
||||
|
||||
// Unix socket
|
||||
await server.listen({ socket: '/var/run/http.sock' })
|
||||
|
||||
// File descriptor
|
||||
await server.listen({ fd: 3 })
|
||||
|
||||
// Systemd socket activation
|
||||
await server.listen({ systemdSocket: 0 })
|
||||
```
|
||||
|
||||
> As a convenience, if `hostname` is `'localhost'`, it will listen on both `127.0.0.1` and `::1`, similar to what Node does when no hostname is provided.
|
||||
|
||||
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 })])
|
||||
console.log('listening on', httpAddr, httpsAddr)
|
||||
```
|
||||
|
||||
### HTTP/2
|
||||
|
||||
Pass a custom `createSecureServer` factory to enable HTTP/2:
|
||||
|
||||
```js
|
||||
import { createSecureServer } from 'node:http2'
|
||||
import { create } from '@vates/http-server-plus'
|
||||
|
||||
const server = create({ createSecureServer }, app)
|
||||
await server.listen({ port: 443, cert, key })
|
||||
```
|
||||
|
||||
### `server.close()`
|
||||
|
||||
Closes all underlying servers. Returns a promise that resolves once the `close` event is emitted.
|
||||
|
||||
```js
|
||||
await server.close()
|
||||
```
|
||||
@@ -1,98 +0,0 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @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])`
|
||||
|
||||
Creates a new server instance. `opts` may contain `createServer` and `createSecureServer` factories (defaults to `http.createServer` and `https.createServer`). `requestListener` is registered as a `request` event listener.
|
||||
|
||||
```js
|
||||
import { create } from '@vates/http-server-plus'
|
||||
|
||||
const server = create(function onRequest(req, res) {
|
||||
res.end('Hello')
|
||||
})
|
||||
```
|
||||
|
||||
### `server.listen(opts)`
|
||||
|
||||
Starts listening. Returns a promise that resolves to a human-readable address string, or rejects with `error.niceAddress` set.
|
||||
|
||||
```js
|
||||
// HTTP on a specific port
|
||||
await server.listen({ port: 8080 })
|
||||
|
||||
// HTTPS — detected automatically when cert+key are provided
|
||||
await server.listen({
|
||||
port: 443,
|
||||
cert: fs.readFileSync('certificate.pem'),
|
||||
key: fs.readFileSync('private_key.pem'),
|
||||
})
|
||||
|
||||
// Unix socket
|
||||
await server.listen({ socket: '/var/run/http.sock' })
|
||||
|
||||
// File descriptor
|
||||
await server.listen({ fd: 3 })
|
||||
|
||||
// Systemd socket activation
|
||||
await server.listen({ systemdSocket: 0 })
|
||||
```
|
||||
|
||||
> As a convenience, if `hostname` is `'localhost'`, it will listen on both `127.0.0.1` and `::1`, similar to what Node does when no hostname is provided.
|
||||
|
||||
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 })])
|
||||
console.log('listening on', httpAddr, httpsAddr)
|
||||
```
|
||||
|
||||
### HTTP/2
|
||||
|
||||
Pass a custom `createSecureServer` factory to enable HTTP/2:
|
||||
|
||||
```js
|
||||
import { createSecureServer } from 'node:http2'
|
||||
import { create } from '@vates/http-server-plus'
|
||||
|
||||
const server = create({ createSecureServer }, app)
|
||||
await server.listen({ port: 443, cert, key })
|
||||
```
|
||||
|
||||
### `server.close()`
|
||||
|
||||
Closes all underlying servers. Returns a promise that resolves once the `close` event is emitted.
|
||||
|
||||
```js
|
||||
await server.close()
|
||||
```
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
the code.
|
||||
|
||||
You may:
|
||||
|
||||
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
|
||||
you've encountered;
|
||||
- fork and create a pull request.
|
||||
|
||||
## License
|
||||
|
||||
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)
|
||||
@@ -1,268 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const EventEmitter = require('events').EventEmitter
|
||||
const formatUrl = require('url').format
|
||||
const fromEvent = require('promise-toolbox/fromEvent')
|
||||
const inherits = require('util').inherits
|
||||
const resolvePath = require('path').resolve
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const forwardedEvents = [
|
||||
'checkContinue',
|
||||
'checkExpectation',
|
||||
'clientError',
|
||||
'connect',
|
||||
'connection',
|
||||
'listening',
|
||||
'request',
|
||||
'secureConnection',
|
||||
'upgrade',
|
||||
]
|
||||
|
||||
function extractProperty(obj, prop) {
|
||||
const value = obj[prop]
|
||||
if (value !== undefined) {
|
||||
delete obj[prop]
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function forOwn(obj, cb) {
|
||||
const keys = Object.keys(obj)
|
||||
for (let i = 0, n = keys.length; i < n; ++i) {
|
||||
cb(obj[keys[i]])
|
||||
}
|
||||
}
|
||||
|
||||
function getSystemdFd(index) {
|
||||
if (process.pid !== +process.env.LISTEN_PID) {
|
||||
throw new Error('systemd sockets are not meant for us (LISTEN_PID)')
|
||||
}
|
||||
|
||||
const listenFds = process.env.LISTEN_FDS
|
||||
if (listenFds == null) {
|
||||
throw new Error('no systemd sockets found (LISTEN_FDS)')
|
||||
}
|
||||
const max = listenFds - 1
|
||||
|
||||
if (index > max) {
|
||||
throw new Error('no such systemd socket found')
|
||||
}
|
||||
|
||||
return index + 3 // 3 = SD_LISTEN_FDS_START
|
||||
}
|
||||
|
||||
const isEmpty = obj => Object.keys(obj).length === 0
|
||||
|
||||
// ===================================================================
|
||||
|
||||
function onNewListener(event, listener) {
|
||||
if (forwardedEvents.includes(event)) {
|
||||
forOwn(this._servers, server => {
|
||||
server.on(event, listener)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onRemoveListener(event, listener) {
|
||||
if (forwardedEvents.includes(event)) {
|
||||
forOwn(this._servers, server => {
|
||||
server.off(event, listener)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function Server(opts) {
|
||||
EventEmitter.call(this)
|
||||
|
||||
this._createServer = (opts != null && opts.createServer) || require('http').createServer
|
||||
this._createSecureServer = (opts != null && opts.createSecureServer) || require('https').createServer
|
||||
this._servers = Object.create(null)
|
||||
this._niceAddresses = Object.create(null)
|
||||
|
||||
this.on('removeListener', onRemoveListener).on('newListener', onNewListener)
|
||||
}
|
||||
inherits(Server, EventEmitter)
|
||||
|
||||
const proto = Server.prototype
|
||||
|
||||
function getAddress(serverId) {
|
||||
return this[serverId].address()
|
||||
}
|
||||
proto.addresses = function Server$addresses() {
|
||||
const servers = this._servers
|
||||
return Object.keys(servers).map(getAddress, servers)
|
||||
}
|
||||
|
||||
function getNiceAddress(serverId) {
|
||||
return this[serverId]
|
||||
}
|
||||
proto.niceAddresses = function Server$niceAddresses() {
|
||||
const niceAddresses = this._niceAddresses
|
||||
return Object.keys(niceAddresses).map(getNiceAddress, niceAddresses)
|
||||
}
|
||||
|
||||
function close(server) {
|
||||
server.close()
|
||||
}
|
||||
|
||||
proto.close = function Server$close(callback) {
|
||||
if (callback) {
|
||||
this.once('close', callback)
|
||||
}
|
||||
|
||||
// Emit the close event even if there are no registered servers.
|
||||
if (isEmpty(this._servers)) {
|
||||
const self = this
|
||||
setImmediate(function () {
|
||||
self.emit('close')
|
||||
})
|
||||
return Promise.resolve()
|
||||
}
|
||||
// Closes each servers.
|
||||
forOwn(this._servers, close)
|
||||
|
||||
return fromEvent(this, 'close')
|
||||
}
|
||||
|
||||
let nextId = 0
|
||||
proto.listen = function Server$listen(opts) {
|
||||
opts = Object.assign({}, opts)
|
||||
|
||||
if (opts.hostname === 'localhost') {
|
||||
return Promise.all([
|
||||
this.listen(Object.assign(opts, { hostname: '::1' })),
|
||||
this.listen(Object.assign(opts, { hostname: '127.0.0.1' })),
|
||||
]).then(addresses => addresses[0])
|
||||
}
|
||||
|
||||
const hostname = extractProperty(opts, 'hostname')
|
||||
let port = extractProperty(opts, 'port')
|
||||
const systemdSocket = extractProperty(opts, 'systemdSocket')
|
||||
let fd = extractProperty(opts, 'fd')
|
||||
let socket = extractProperty(opts, 'socket')
|
||||
|
||||
const servers = this._servers
|
||||
|
||||
const niceAddresses = this._niceAddresses
|
||||
|
||||
let server, protocol
|
||||
if (opts.pfx || opts.SNICallback || (opts.cert && opts.key)) {
|
||||
server = this._createSecureServer(opts)
|
||||
protocol = 'https'
|
||||
} else {
|
||||
server = this._createServer(opts)
|
||||
protocol = 'http'
|
||||
}
|
||||
|
||||
const id = nextId++
|
||||
servers[id] = server
|
||||
|
||||
if (systemdSocket != null) {
|
||||
fd = getSystemdFd(systemdSocket)
|
||||
}
|
||||
|
||||
// Compute a temporary nice address to display in case of error.
|
||||
let niceAddress
|
||||
if (fd != null) {
|
||||
server.listen({ fd })
|
||||
niceAddress = protocol + '://<fd:' + fd + '>'
|
||||
} else if (socket != null) {
|
||||
socket = resolvePath(socket)
|
||||
server.listen(socket)
|
||||
niceAddress = protocol + '://' + socket
|
||||
} else {
|
||||
if (port == null) {
|
||||
port = protocol === 'https' ? 443 : 80
|
||||
}
|
||||
server.listen(port, hostname)
|
||||
niceAddress = formatUrl({
|
||||
protocol,
|
||||
|
||||
// Hostname default to localhost.
|
||||
hostname: hostname || 'localhost',
|
||||
|
||||
// No port means random, unknown for now.
|
||||
port: port || '<unknown>',
|
||||
})
|
||||
}
|
||||
|
||||
const emit = this.emit.bind(this)
|
||||
server.once('close', function onClose() {
|
||||
delete servers[id]
|
||||
delete niceAddresses[id]
|
||||
|
||||
if (isEmpty(servers)) {
|
||||
emit('close')
|
||||
}
|
||||
})
|
||||
|
||||
server.on('error', function onError() {
|
||||
delete servers[id]
|
||||
|
||||
// FIXME: Should it be forwarded and be fatal if there are no
|
||||
// listeners?
|
||||
})
|
||||
|
||||
forwardedEvents.forEach(event => {
|
||||
this.listeners(event).forEach(listener => {
|
||||
server.on(event, listener)
|
||||
})
|
||||
})
|
||||
|
||||
return fromEvent(server, 'listening').then(
|
||||
function () {
|
||||
const address = server.address()
|
||||
const niceAddress =
|
||||
typeof address === 'string'
|
||||
? protocol + '://' + address
|
||||
: formatUrl({
|
||||
protocol,
|
||||
hostname: address.address,
|
||||
port: address.port,
|
||||
})
|
||||
niceAddresses[id] = niceAddress
|
||||
return niceAddress
|
||||
},
|
||||
function (error) {
|
||||
error.niceAddress = niceAddress
|
||||
throw error
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function ref(server) {
|
||||
server.ref()
|
||||
}
|
||||
|
||||
proto.ref = function Server$ref() {
|
||||
forOwn(this._servers, ref)
|
||||
}
|
||||
|
||||
function unref(server) {
|
||||
server.unref()
|
||||
}
|
||||
|
||||
proto.unref = function Server$unref() {
|
||||
forOwn(this._servers, unref)
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
module.exports = exports = Server
|
||||
|
||||
exports.create = function create(opts, requestListener) {
|
||||
if (typeof opts === 'function') {
|
||||
requestListener = opts
|
||||
opts = undefined
|
||||
}
|
||||
|
||||
const server = new Server(opts)
|
||||
if (requestListener !== undefined) {
|
||||
server.on('request', requestListener)
|
||||
}
|
||||
return server
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const assert = require('assert/strict')
|
||||
const { EventEmitter } = require('events')
|
||||
const resolvePath = require('path').resolve
|
||||
const { describe, it } = require('node:test')
|
||||
|
||||
const HttpServerPlus = require('./')
|
||||
|
||||
// ===================================================================
|
||||
|
||||
// Creates a minimal mock server that can be used as a drop-in for http/https.Server.
|
||||
// `address` is the value returned by mock.address() — use an object for TCP, a string for sockets.
|
||||
function createMock(address) {
|
||||
const mock = new EventEmitter()
|
||||
mock.listen = function () {
|
||||
setImmediate(() => this.emit('listening'))
|
||||
}
|
||||
mock.address = () => address
|
||||
mock.close = function () {
|
||||
setImmediate(() => this.emit('close'))
|
||||
}
|
||||
return mock
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
describe('HttpServerPlus', function () {
|
||||
describe('#create()', function () {
|
||||
it('creates a new instance', function () {
|
||||
assert(HttpServerPlus.create() instanceof HttpServerPlus)
|
||||
})
|
||||
|
||||
it('can register a `request` listener', function () {
|
||||
const listener = function () {}
|
||||
const server = HttpServerPlus.create(listener)
|
||||
|
||||
assert.deepEqual(server.listeners('request'), [listener])
|
||||
})
|
||||
})
|
||||
|
||||
describe('.addresses()', function () {
|
||||
it('returns the list of addresses the server is listening at', function () {
|
||||
const addr = { address: '127.0.0.1', port: 8080, family: 'IPv4' }
|
||||
const mock = createMock(addr)
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
return server.listen({ port: 8080 }).then(function () {
|
||||
assert.deepEqual(server.addresses(), [addr])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('.niceAddresses()', function () {
|
||||
it('returns the list of human readable addresses the server is listening at', function () {
|
||||
const mock = createMock({ address: '127.0.0.1', port: 8080 })
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
return server.listen({ hostname: '127.0.0.1', port: 8080 }).then(function (address) {
|
||||
assert.deepEqual(server.niceAddresses(), [address])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('secureConnection event forwarding', function () {
|
||||
function createMockHttpsServer() {
|
||||
const server = new EventEmitter()
|
||||
server.listen = function () {
|
||||
setImmediate(() => this.emit('listening'))
|
||||
}
|
||||
server.address = () => ({ address: '127.0.0.1', port: 8443 })
|
||||
server.close = function () {
|
||||
setImmediate(() => this.emit('close'))
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
function createServerWithMock() {
|
||||
let mockServer
|
||||
const server = HttpServerPlus.create({
|
||||
createSecureServer: () => {
|
||||
mockServer = createMockHttpsServer()
|
||||
return mockServer
|
||||
},
|
||||
})
|
||||
return {
|
||||
server,
|
||||
getMock: () => mockServer,
|
||||
}
|
||||
}
|
||||
|
||||
it('forwards secureConnection to listeners registered before listen()', function () {
|
||||
const { server, getMock } = createServerWithMock()
|
||||
|
||||
const received = []
|
||||
server.on('secureConnection', socket => received.push(socket))
|
||||
|
||||
const fakeSocket = {}
|
||||
return server.listen({ cert: 'fake-cert', key: 'fake-key' }).then(function () {
|
||||
getMock().emit('secureConnection', fakeSocket)
|
||||
assert.deepEqual(received, [fakeSocket])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards secureConnection to listeners registered after listen()', function () {
|
||||
const { server, getMock } = createServerWithMock()
|
||||
|
||||
return server.listen({ cert: 'fake-cert', key: 'fake-key' }).then(function () {
|
||||
const received = []
|
||||
server.on('secureConnection', socket => received.push(socket))
|
||||
|
||||
const fakeSocket = {}
|
||||
getMock().emit('secureConnection', fakeSocket)
|
||||
assert.deepEqual(received, [fakeSocket])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('stops forwarding secureConnection after listener is removed', function () {
|
||||
const { server, getMock } = createServerWithMock()
|
||||
|
||||
const received = []
|
||||
const listener = socket => received.push(socket)
|
||||
server.on('secureConnection', listener)
|
||||
|
||||
return server.listen({ cert: 'fake-cert', key: 'fake-key' }).then(function () {
|
||||
server.off('secureConnection', listener)
|
||||
|
||||
getMock().emit('secureConnection', {})
|
||||
assert.deepEqual(received, [])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('.listen()', function () {
|
||||
it('can use a host:port', function () {
|
||||
let listenArgs
|
||||
const mock = createMock({ address: '127.0.0.1', port: 8080 })
|
||||
mock.listen = function (...args) {
|
||||
listenArgs = args
|
||||
setImmediate(() => this.emit('listening'))
|
||||
}
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
return server.listen({ hostname: '127.0.0.1', port: 8080 }).then(function () {
|
||||
assert.deepEqual(listenArgs, [8080, '127.0.0.1'])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('can use a systemd socket', function () {
|
||||
const SD_LISTEN_FDS_START = 3
|
||||
const SOCKET_INDEX = 0
|
||||
|
||||
let listenArgs
|
||||
const mock = createMock('http://<fd:3>')
|
||||
mock.listen = function (...args) {
|
||||
listenArgs = args
|
||||
setImmediate(() => this.emit('listening'))
|
||||
}
|
||||
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
const env = process.env
|
||||
env.LISTEN_FDS = String(SOCKET_INDEX + 1)
|
||||
env.LISTEN_PID = String(process.pid)
|
||||
|
||||
return server.listen({ systemdSocket: SOCKET_INDEX }).then(function () {
|
||||
delete env.LISTEN_FDS
|
||||
delete env.LISTEN_PID
|
||||
|
||||
assert.deepEqual(listenArgs, [{ fd: SOCKET_INDEX + SD_LISTEN_FDS_START }])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('can use a socket', function () {
|
||||
const socketPath = '/tmp/http-server-plus-test.sock'
|
||||
let listenArgs
|
||||
const mock = createMock(socketPath)
|
||||
mock.listen = function (...args) {
|
||||
listenArgs = args
|
||||
setImmediate(() => this.emit('listening'))
|
||||
}
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
return server.listen({ socket: socketPath }).then(function () {
|
||||
assert.deepEqual(listenArgs, [resolvePath(socketPath)])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('can use a HTTPS certificate', function () {
|
||||
let capturedOpts
|
||||
const mock = createMock({ address: '127.0.0.1', port: 8443 })
|
||||
const server = HttpServerPlus.create({
|
||||
createSecureServer: opts => {
|
||||
capturedOpts = opts
|
||||
return mock
|
||||
},
|
||||
})
|
||||
|
||||
return server.listen({ cert: 'my-cert', key: 'my-key', port: 8443 }).then(function (address) {
|
||||
assert.equal(capturedOpts.cert, 'my-cert')
|
||||
assert.equal(capturedOpts.key, 'my-key')
|
||||
assert.ok(address.startsWith('https://'), `expected https URL, got: ${address}`)
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a promise which will resolve once listening', function () {
|
||||
const mock = createMock({ address: '127.0.0.1', port: 8080 })
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
return server.listen({ port: 8080 }).then(function (address) {
|
||||
assert.equal(typeof address, 'string')
|
||||
assert.ok(address.startsWith('http://'), `expected http URL, got: ${address}`)
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a promise which will reject if failure', function () {
|
||||
const listenError = new Error('EADDRINUSE')
|
||||
const mock = createMock(null)
|
||||
mock.listen = function () {
|
||||
setImmediate(() => this.emit('error', listenError))
|
||||
}
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
return server.listen({ port: 8080 }).then(
|
||||
function () {
|
||||
assert.fail('should have rejected')
|
||||
},
|
||||
function (err) {
|
||||
assert.equal(err, listenError)
|
||||
assert.equal(typeof err.niceAddress, 'string')
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('.close()', function () {
|
||||
it('closes all servers', function () {
|
||||
const closed = []
|
||||
let callCount = 0
|
||||
|
||||
const server = HttpServerPlus.create({
|
||||
createServer: () => {
|
||||
const id = callCount++
|
||||
const mock = createMock({ address: '127.0.0.1', port: id })
|
||||
mock.close = function () {
|
||||
closed.push(id)
|
||||
setImmediate(() => this.emit('close'))
|
||||
}
|
||||
return mock
|
||||
},
|
||||
})
|
||||
|
||||
return Promise.all([server.listen({ port: 0 }), server.listen({ port: 0 })])
|
||||
.then(function () {
|
||||
return server.close()
|
||||
})
|
||||
.then(function () {
|
||||
assert.equal(closed.length, 2)
|
||||
})
|
||||
})
|
||||
|
||||
it('emit the `close` event when all servers are closed', function () {
|
||||
const mock = createMock({ address: '127.0.0.1', port: 8080 })
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
let closeEmitted = false
|
||||
server.on('close', () => {
|
||||
closeEmitted = true
|
||||
})
|
||||
|
||||
return server
|
||||
.listen({ port: 8080 })
|
||||
.then(function () {
|
||||
return server.close()
|
||||
})
|
||||
.then(function () {
|
||||
assert.ok(closeEmitted)
|
||||
})
|
||||
})
|
||||
|
||||
it('emit the `close` event even if there are no servers', function () {
|
||||
const server = HttpServerPlus.create()
|
||||
|
||||
// close() with no servers returns Promise.resolve() immediately (before emitting
|
||||
// 'close' via setImmediate), so we must wait for the event itself, not the promise.
|
||||
return new Promise(function (resolve) {
|
||||
server.close(resolve)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"name": "@vates/http-server-plus",
|
||||
"version": "2.0.2",
|
||||
"license": "ISC",
|
||||
"description": "Augmented `http.Server`, HTTP/HTTPS/HTTP2 and multiple ports on the same instance",
|
||||
"keywords": [
|
||||
"http",
|
||||
"https",
|
||||
"server",
|
||||
"ports"
|
||||
],
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/http-server-plus",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@vates/http-server-plus",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"promise-toolbox": "^0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-plugin-n": "^15.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"pretest": "eslint .",
|
||||
"test": "node --test index.spec.js",
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"private": false,
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { RandomAccessDisk } from '@xen-orchestra/disk-transform'
|
||||
import NbdClient from '@vates/nbd-client'
|
||||
|
||||
/**
|
||||
* @typedef {{ offset: number, length: number }} DataRange
|
||||
*/
|
||||
|
||||
/**
|
||||
* @extends {RandomAccessDisk}
|
||||
*/
|
||||
export class NbdDisk extends RandomAccessDisk {
|
||||
#nbdInfos
|
||||
/** @type {NbdClient|undefined} */
|
||||
#nbdClient
|
||||
|
||||
/** @type {Array<DataRange> | undefined} */
|
||||
#dataMap
|
||||
|
||||
/** @type {number} */
|
||||
#blockSize
|
||||
|
||||
/** @type {number | undefined} */
|
||||
#hasBlockCursor
|
||||
|
||||
/** @type {number | undefined} */
|
||||
#hasBlockPreviousIndex
|
||||
|
||||
constructor(nbdInfos, blockSize, { dataMap } = {}) {
|
||||
super()
|
||||
this.#blockSize = blockSize
|
||||
this.#nbdInfos = nbdInfos
|
||||
this.#dataMap = dataMap && this.#processDatamap(dataMap)
|
||||
}
|
||||
|
||||
#processDatamap(rawDataMap) {
|
||||
const ranges = rawDataMap
|
||||
.filter(({ type, length }) => type === 0 && length > 0)
|
||||
.map(({ offset, length }) => ({ offset, length }))
|
||||
.sort(({ offset: offset1 }, { offset: offset2 }) => offset1 - offset2)
|
||||
|
||||
// hasBlock()'s forward-only cursor is only correct if the extents are
|
||||
// sorted AND disjoint: an earlier extent must never reach into a block
|
||||
// after a later one, otherwise the cursor could skip it and wrongly report
|
||||
// a block as empty, dropping data and corrupting the output.
|
||||
// Touching extents (cur.offset === prevEnd) are merged
|
||||
const merged = []
|
||||
for (const range of ranges) {
|
||||
const last = merged[merged.length - 1]
|
||||
if (last !== undefined) {
|
||||
const lastEnd = last.offset + last.length
|
||||
if (range.offset < lastEnd) {
|
||||
throw new Error(
|
||||
`overlapping ranges in data map: [${last.offset}, ${lastEnd}) and [${range.offset}, ${range.offset + range.length})`
|
||||
)
|
||||
}
|
||||
if (range.offset === lastEnd) {
|
||||
// touching: extend the previous extent
|
||||
last.length += range.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
// gap (or first range): new extent
|
||||
merged.push(range)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @returns {Promise<DiskBlock>}
|
||||
*/
|
||||
async readBlock(index) {
|
||||
if (!this.#nbdClient) {
|
||||
throw new Error("can't readBlock before init")
|
||||
}
|
||||
let data = await this.#nbdClient.readBlock(index, this.getBlockSize())
|
||||
if (data.length !== this.getBlockSize()) {
|
||||
// this condition only work on non aligned disks
|
||||
// we won't accept truncated block from unaligned disks
|
||||
if (index === Math.ceil(this.getVirtualSize() / this.getBlockSize()) - 1) {
|
||||
// last block add zeros at the end
|
||||
data = Buffer.concat([data], this.getBlockSize())
|
||||
} else {
|
||||
throw new Error(`Block ${index} is not at the right size, expecting ${this.getBlockSize()}, got ${data.length}`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
index,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
getVirtualSize() {
|
||||
if (!this.#nbdClient) {
|
||||
throw new Error("can't get size before init")
|
||||
}
|
||||
return Number(this.#nbdClient.exportSize)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
getBlockSize() {
|
||||
return this.#blockSize
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async init() {
|
||||
this.#nbdClient = new NbdClient(this.#nbdInfos)
|
||||
await this.#nbdClient.connect()
|
||||
if (this.#dataMap === undefined) {
|
||||
this.#dataMap = this.#processDatamap(await this.#nbdClient.getMap())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async close() {
|
||||
await this.#nbdClient?.disconnect()
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isDifferencing() {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array<number>}
|
||||
*/
|
||||
getBlockIndexes() {
|
||||
const indexes = new Set()
|
||||
this.#dataMap?.forEach(({ offset, length }) => {
|
||||
if (length === 0) return
|
||||
|
||||
const firstBlockIndex = Math.floor(offset / this.getBlockSize())
|
||||
const lastBlockIndex = Math.floor((offset + length - 1) / this.getBlockSize())
|
||||
|
||||
for (let blockIndex = firstBlockIndex; blockIndex <= lastBlockIndex; blockIndex++) {
|
||||
indexes.add(blockIndex)
|
||||
}
|
||||
})
|
||||
return [...indexes].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the allocated blocks without materializing the index list.
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
getBlockIndexesCount() {
|
||||
if (!this.#dataMap) {
|
||||
throw new Error("can't getBlockIndexesCount before init")
|
||||
}
|
||||
|
||||
const blockSize = this.getBlockSize()
|
||||
|
||||
let count = 0
|
||||
let lastCountedBlock = -1
|
||||
for (const { offset, length } of this.#dataMap) {
|
||||
const firstBlockIndex = Math.floor(offset / blockSize)
|
||||
const lastBlockIndex = Math.floor((offset + length - 1) / blockSize)
|
||||
const from = Math.max(firstBlockIndex, lastCountedBlock + 1)
|
||||
|
||||
if (lastBlockIndex >= from) {
|
||||
count += lastBlockIndex - from + 1
|
||||
lastCountedBlock = lastBlockIndex
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasBlock(index) {
|
||||
if (!this.#dataMap) {
|
||||
throw new Error("can't hasBlock before init")
|
||||
}
|
||||
|
||||
const blockSize = this.getBlockSize()
|
||||
const blockStart = index * blockSize
|
||||
const blockEnd = blockStart + blockSize
|
||||
|
||||
let startExtentIndex = 0
|
||||
if (this.#hasBlockCursor !== undefined && index >= this.#hasBlockPreviousIndex) {
|
||||
startExtentIndex = this.#hasBlockCursor
|
||||
} else {
|
||||
this.#hasBlockCursor = undefined
|
||||
}
|
||||
this.#hasBlockPreviousIndex = index
|
||||
|
||||
const dataMap = this.#dataMap
|
||||
const l = dataMap.length
|
||||
for (let i = startExtentIndex; i < l; i++) {
|
||||
const { offset, length } = dataMap[i]
|
||||
if (offset >= blockEnd) {
|
||||
// extents are sorted: nothing from here on can overlap this block
|
||||
break
|
||||
}
|
||||
if (offset + length > blockStart) {
|
||||
this.#hasBlockCursor = i
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import { NbdDisk } from './NbdDisk.mjs'
|
||||
|
||||
const KiB = 1024
|
||||
const MiB = 1024 * KiB
|
||||
const TiB = 1024 * 1024 * MiB
|
||||
|
||||
const SOURCE_BLOCK_SIZE = 2 * MiB // VHD_BLOCK_SIZE, the block size used when importing from VMware
|
||||
const CLUSTER_SIZE = 64 * KiB // qcow2 cluster size
|
||||
|
||||
// hasBlock() only needs the dataMap (passed through the constructor) and the
|
||||
// block size, so we can exercise it without a live NBD connection.
|
||||
describe('NbdDisk.hasBlock', () => {
|
||||
it('reports allocated and unallocated blocks correctly', () => {
|
||||
const dataMap = [
|
||||
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE }, // block 0
|
||||
{ type: 0, offset: 5 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 5
|
||||
]
|
||||
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
|
||||
|
||||
assert.strictEqual(disk.hasBlock(0), true)
|
||||
assert.strictEqual(disk.hasBlock(1), false)
|
||||
assert.strictEqual(disk.hasBlock(4), false)
|
||||
assert.strictEqual(disk.hasBlock(5), true)
|
||||
assert.strictEqual(disk.hasBlock(6), false)
|
||||
})
|
||||
|
||||
it('handles repeated and out-of-order queries with the resume cursor', () => {
|
||||
const dataMap = [
|
||||
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE }, // block 0
|
||||
{ type: 0, offset: 5 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 5
|
||||
]
|
||||
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
|
||||
|
||||
// repeated indexes (the qcow2 path queries each 2MB block ~32 times as it
|
||||
// walks 64KB clusters) must return a stable answer
|
||||
assert.strictEqual(disk.hasBlock(5), true)
|
||||
assert.strictEqual(disk.hasBlock(5), true)
|
||||
assert.strictEqual(disk.hasBlock(6), false)
|
||||
assert.strictEqual(disk.hasBlock(6), false)
|
||||
|
||||
// querying backwards must still be correct (falls back to a full scan)
|
||||
assert.strictEqual(disk.hasBlock(0), true)
|
||||
assert.strictEqual(disk.hasBlock(5), true)
|
||||
})
|
||||
|
||||
it('invalidates the cursor on a backward miss (no stale skip)', () => {
|
||||
const dataMap = [
|
||||
{ type: 0, offset: 3 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 3
|
||||
{ type: 0, offset: 10 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 10
|
||||
]
|
||||
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
|
||||
|
||||
assert.strictEqual(disk.hasBlock(10), true) // forward hit: cursor parks on the high extent
|
||||
assert.strictEqual(disk.hasBlock(1), false) // backward miss: must invalidate the cursor
|
||||
assert.strictEqual(disk.hasBlock(3), true) // lower extent must not be skipped
|
||||
})
|
||||
|
||||
it('throws on overlapping ranges (would break the resume cursor)', () => {
|
||||
const dataMap = [
|
||||
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE },
|
||||
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: SOURCE_BLOCK_SIZE }, // overlaps the previous range
|
||||
]
|
||||
assert.throws(() => new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap }), /overlapping ranges/)
|
||||
})
|
||||
|
||||
it('merges touching ranges and drops zero-length / non type-0 entries', () => {
|
||||
const dataMap = [
|
||||
{ type: 0, offset: 2 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // [2, 3)
|
||||
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE / 2 }, // [0, .5)
|
||||
{ type: 0, offset: SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // [1, 2)
|
||||
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: SOURCE_BLOCK_SIZE / 2 }, // [.5, 1), touches [0, .5)
|
||||
{ type: 1, offset: 10 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // wrong type: ignored
|
||||
{ type: 0, offset: 7 * SOURCE_BLOCK_SIZE, length: 0 }, // zero length: ignored
|
||||
]
|
||||
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
|
||||
|
||||
assert.strictEqual(disk.hasBlock(0), true)
|
||||
assert.strictEqual(disk.hasBlock(1), true)
|
||||
assert.strictEqual(disk.hasBlock(2), true)
|
||||
assert.strictEqual(disk.hasBlock(3), false)
|
||||
assert.strictEqual(disk.hasBlock(7), false) // zero-length range dropped
|
||||
assert.strictEqual(disk.hasBlock(10), false) // non type-0 range dropped
|
||||
})
|
||||
})
|
||||
|
||||
describe('NbdDisk.getBlockIndexesCount', () => {
|
||||
const cases = {
|
||||
'aligned, disjoint blocks': [
|
||||
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE }, // block 0
|
||||
{ type: 0, offset: 5 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 5
|
||||
],
|
||||
'a range spanning several blocks': [
|
||||
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: 3 * SOURCE_BLOCK_SIZE }, // blocks 0,1,2,3
|
||||
],
|
||||
'two disjoint extents sharing block 0 (needs dedup)': [
|
||||
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE / 2 }, // [0, .5) => block 0
|
||||
{ type: 0, offset: 3 * (SOURCE_BLOCK_SIZE / 4), length: SOURCE_BLOCK_SIZE }, // [.75, 1.75) => blocks 0,1
|
||||
],
|
||||
'touching sub-block fragments': [
|
||||
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE / 2 },
|
||||
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: SOURCE_BLOCK_SIZE / 2 },
|
||||
{ type: 0, offset: SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE },
|
||||
],
|
||||
empty: [],
|
||||
}
|
||||
|
||||
for (const [name, dataMap] of Object.entries(cases)) {
|
||||
it(`matches getBlockIndexes().length: ${name}`, () => {
|
||||
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
|
||||
assert.strictEqual(disk.getBlockIndexesCount(), disk.getBlockIndexes().length)
|
||||
})
|
||||
}
|
||||
|
||||
it('matches getBlockIndexes().length on the large sparse map', () => {
|
||||
const virtualSize = Math.floor(3.5 * TiB)
|
||||
const NB_RANGES = 113528
|
||||
const dataMap = []
|
||||
for (let i = 0; i < NB_RANGES; i++) {
|
||||
dataMap.push({ type: 0, offset: Math.floor((i / NB_RANGES) * virtualSize), length: CLUSTER_SIZE })
|
||||
}
|
||||
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
|
||||
assert.strictEqual(disk.getBlockIndexesCount(), disk.getBlockIndexes().length)
|
||||
})
|
||||
})
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
OPTS_MAGIC,
|
||||
NBD_CMD_DISC,
|
||||
} from './constants.mjs'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const { warn } = createLogger('vates:nbd-client')
|
||||
|
||||
@@ -353,56 +352,10 @@ export default class NbdClient {
|
||||
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
|
||||
return pRetry(() => this.#readBlock(index, size), {
|
||||
tries: this.#readBlockRetries,
|
||||
when: error => error.code !== 'ERR_ABORTED',
|
||||
onRetry: async err => {
|
||||
warn('will retry reading block ', index, err)
|
||||
await this.reconnect()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* returns the map of the file with holes, zeros and data, useful to handle efficiently sparse source
|
||||
* to implement this internally: use structure response if the server supports it, and then ask for BLOCK_STATUS *
|
||||
*
|
||||
* @returns {Promise<{ offset: number, length: number, type: number }[]>}
|
||||
* A promise that resolves to an array where each object represents a segment:
|
||||
* - `offset` — The byte offset from the start.
|
||||
* - `length` — The size of the segment in bytes.
|
||||
* - `type` — A numeric code indicating the segment type ( 0 means data).
|
||||
*/
|
||||
/* async */ getMap(signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = spawn('nbdinfo', [
|
||||
'--json',
|
||||
'--map',
|
||||
`nbd://${this.#serverAddress}:${this.#serverPort}/${encodeURIComponent(this.#exportName)}`,
|
||||
])
|
||||
let text = ''
|
||||
let errText = ''
|
||||
process.stdout.on('data', data => (text += data))
|
||||
process.stderr.on('data', data => (errText += data))
|
||||
const onAbort = () => {
|
||||
process.kill()
|
||||
reject(signal.reason)
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
process.on('error', err => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
reject(err)
|
||||
})
|
||||
process.on('close', code => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`Error during getMap (code: ${code}): ${errText}`))
|
||||
}
|
||||
try {
|
||||
const json = JSON.parse(text)
|
||||
resolve(json)
|
||||
} catch (error) {
|
||||
reject(new Error(`${error} ${text}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,17 +129,4 @@ export default class MultiNbdClient {
|
||||
yield readAhead.shift()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* returns the map of the file with holes, zeros and data, useful to handle efficiently sparse source *
|
||||
*
|
||||
* @returns {Promise<{ offset: number, length: number, type: number }[]>}
|
||||
* A promise that resolves to an array where each object represents a segment:
|
||||
* - `offset` — The byte offset from the start.
|
||||
* - `length` — The size of the segment in bytes.
|
||||
* - `type` — A numeric code indicating the segment type (0 means no data).
|
||||
*/
|
||||
async getMap(signal) {
|
||||
// ask the map from one of the connected client
|
||||
return this.#clients[Math.floor(this.#clients.length * Math.random())].getMap(signal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,19 +13,18 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "3.5.0",
|
||||
"version": "3.1.4",
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
},
|
||||
"main": "./index.mjs",
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@vates/read-chunk": "^1.2.1",
|
||||
"@xen-orchestra/async-map": "^0.1.3",
|
||||
"@xen-orchestra/disk-transform": "^1.3.3",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"@vates/read-chunk": "^1.2.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"xen-api": "^4.8.0"
|
||||
"xen-api": "^4.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^18.7.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vates/node-vsphere-soap",
|
||||
"version": "2.1.3",
|
||||
"version": "2.1.2",
|
||||
"description": "interface to vSphere SOAP/WSDL from node for interfacing with vCenter or ESXi, forked from node-vsphere-soap",
|
||||
"main": "lib/client.mjs",
|
||||
"author": "reedog117",
|
||||
@@ -11,8 +11,8 @@
|
||||
},
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"axios": "^1.15.0",
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"axios": "^1.8.0",
|
||||
"soap": "^1.0.0",
|
||||
"soap-cookie": "^0.10.1"
|
||||
},
|
||||
|
||||
@@ -71,17 +71,13 @@ exports.readChunk = readChunk
|
||||
* If `size` bytes are not available to be read, the returned promise is rejected.
|
||||
*
|
||||
* @param {Readable} stream - A readable stream to read from.
|
||||
* @param {number?} [size] - The number of bytes to read for binary streams (ignored for object streams).
|
||||
* @param {number} [size] - The number of bytes to read for binary streams (ignored for object streams).
|
||||
* @returns {Promise<Buffer|string|unknown>} - A Promise that resolves to the read chunk.
|
||||
*/
|
||||
exports.readChunkStrict = async function readChunkStrict(stream, size) {
|
||||
const chunk = await readChunk(stream, size)
|
||||
if (chunk === null) {
|
||||
if (size === undefined) {
|
||||
throw new Error(`stream has ended without data`)
|
||||
} else {
|
||||
throw new Error(`stream has ended without data, was looking for ${size} bytes`)
|
||||
}
|
||||
throw new Error('stream has ended without data')
|
||||
}
|
||||
|
||||
if (size !== undefined && chunk.length !== size) {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.0",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noo
|
||||
Object.defineProperty(taskLog, '$root', { value: taskLog })
|
||||
|
||||
// start of a root task
|
||||
onRootTaskStart(taskLog, event)
|
||||
onRootTaskStart(taskLog)
|
||||
} else {
|
||||
// start of a subtask
|
||||
const parent = taskLogs.get(parentId)
|
||||
@@ -54,10 +54,10 @@ exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noo
|
||||
}
|
||||
|
||||
if (type === 'end' && taskLog.$root === taskLog) {
|
||||
onRootTaskEnd(taskLog, event)
|
||||
onRootTaskEnd(taskLog)
|
||||
}
|
||||
}
|
||||
|
||||
onTaskUpdate(taskLog, event)
|
||||
onTaskUpdate(taskLog)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,27 +7,6 @@ function alreadyEnded() {
|
||||
throw new Error('task has already ended')
|
||||
}
|
||||
|
||||
const serializeErrors = errors => (Array.isArray(errors) ? errors.map(serializeError) : errors)
|
||||
|
||||
// Create a serializable object from an error.
|
||||
//
|
||||
// Otherwise some fields might be non-enumerable and missing from logs.
|
||||
const serializeError = error => {
|
||||
return error instanceof Error
|
||||
? {
|
||||
...error, // Copy enumerable properties.
|
||||
cause: error.cause,
|
||||
code: error.code,
|
||||
errors: serializeErrors(error.errors), // supports AggregateError
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
stack: error.stack,
|
||||
}
|
||||
: error
|
||||
}
|
||||
|
||||
exports.serializeError = serializeError
|
||||
|
||||
// define a read-only, non-enumerable, non-configurable property
|
||||
function define(object, property, value) {
|
||||
Object.defineProperty(object, property, { value })
|
||||
@@ -93,11 +72,7 @@ class Task {
|
||||
_onProgress
|
||||
|
||||
get id() {
|
||||
// Use a compact, sortable, string representation of the creation date,
|
||||
// and add a random part to differentiate tasks created at the same time
|
||||
//
|
||||
// Due to the padding, dates are sortable up to 5188-04-22T11:04:28.415Z
|
||||
return (this.id = Date.now().toString(36).padStart(9, '0') + '-' + Math.random().toString(36).slice(2))
|
||||
return (this.id = Math.random().toString(36).slice(2))
|
||||
}
|
||||
set id(value) {
|
||||
define(this, 'id', value)
|
||||
@@ -161,10 +136,6 @@ class Task {
|
||||
assert.equal(this.#status, PENDING)
|
||||
assert.equal(this.#running, false)
|
||||
|
||||
if (result instanceof Error && result.toJSON === undefined) {
|
||||
result = serializeError(result)
|
||||
}
|
||||
|
||||
this.#emit('end', { status, result })
|
||||
this._onProgress = alreadyEnded
|
||||
this.#status = status
|
||||
|
||||
@@ -4,7 +4,7 @@ const assert = require('node:assert').strict
|
||||
const { describe, it } = require('node:test')
|
||||
|
||||
const { makeOnProgress } = require('./combineEvents.js')
|
||||
const { Task, serializeError } = require('./index.js')
|
||||
const { Task } = require('./index.js')
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
@@ -28,36 +28,6 @@ function createTask(opts) {
|
||||
return task
|
||||
}
|
||||
|
||||
describe('serializeError', function () {
|
||||
it('serializes simple error', function () {
|
||||
const err = new Error('test Error')
|
||||
err.code = 404
|
||||
const serialized = serializeError(err)
|
||||
assert.equal(serialized.message, 'test Error')
|
||||
assert.equal(serialized.name, 'Error')
|
||||
assert.equal(serialized.code, 404)
|
||||
assert.notEqual(serialized.stack, undefined)
|
||||
})
|
||||
|
||||
it('serializes aggregate error', function () {
|
||||
const err = new AggregateError([new Error('test error 1'), new Error('test error 2')], 'Error message', {
|
||||
cause: 'broken',
|
||||
})
|
||||
const serialized = serializeError(err)
|
||||
assert.equal(serialized.message, 'Error message')
|
||||
assert.equal(serialized.name, 'AggregateError')
|
||||
assert.equal(serialized.cause, 'broken')
|
||||
assert.notEqual(serialized.stack, undefined)
|
||||
assert.equal(serialized.errors.length, 2)
|
||||
assert.equal(serialized.errors[0].name, 'Error')
|
||||
assert.equal(serialized.errors[1].name, 'Error')
|
||||
assert.equal(serialized.errors[0].message, 'test error 1')
|
||||
assert.equal(serialized.errors[1].message, 'test error 2')
|
||||
assert.notEqual(serialized.errors[0].stack, undefined)
|
||||
assert.notEqual(serialized.errors[1].stack, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Task', function () {
|
||||
describe('constructor', function () {
|
||||
it('data properties are passed to the start event', async function () {
|
||||
@@ -136,7 +106,7 @@ describe('Task', function () {
|
||||
assert.equal(task.$events.length, 3)
|
||||
assertEvent(task, { type: 'start' }, 0)
|
||||
assertEvent(task, { type: 'abortionRequested', reason }, 1)
|
||||
assertEvent(task, { type: 'end', status: 'failure', result: serializeError(result) }, 2)
|
||||
assertEvent(task, { type: 'end', status: 'failure', result }, 2)
|
||||
})
|
||||
|
||||
it('does not abort if the task succeed', async function () {
|
||||
@@ -206,7 +176,7 @@ describe('Task', function () {
|
||||
|
||||
assertEvent(task, {
|
||||
status: 'failure',
|
||||
result: serializeError(error),
|
||||
result: error,
|
||||
type: 'end',
|
||||
})
|
||||
})
|
||||
@@ -505,7 +475,7 @@ describe('Task', function () {
|
||||
assert.equal(task.status, 'failure')
|
||||
assertEvent(task, {
|
||||
status: 'failure',
|
||||
result: serializeError(e),
|
||||
result: e,
|
||||
type: 'end',
|
||||
})
|
||||
})
|
||||
@@ -541,7 +511,7 @@ describe('Task', function () {
|
||||
assert.equal(task.status, 'failure')
|
||||
assertEvent(task, {
|
||||
status: 'failure',
|
||||
result: serializeError(e),
|
||||
result: e,
|
||||
type: 'end',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.7.0",
|
||||
"version": "0.6.2",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
|
||||
@@ -2,17 +2,8 @@
|
||||
|
||||
import eslint from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended, {
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
},
|
||||
ignores: ['dist'],
|
||||
rules: {
|
||||
// Allow empty interface for recursive generic typing (E.g. interface XenApiNetworkWrapped)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@vates/types",
|
||||
"private": false,
|
||||
"version": "1.30.1",
|
||||
"version": "1.10.0",
|
||||
"main": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -9,7 +9,6 @@
|
||||
"types": "./dist/index.d.mts"
|
||||
},
|
||||
"./xo": "./dist/xo.d.mts",
|
||||
"./xo-app": "./dist/xo-app.d.mts",
|
||||
"./xen-api": "./dist/xen-api.d.mts",
|
||||
"./common": {
|
||||
"default": "./dist/common.mjs",
|
||||
@@ -22,18 +21,13 @@
|
||||
"./lib/xen-orchestra/*": {
|
||||
"default": "./dist/lib/xen-orchestra-*.mjs",
|
||||
"type": "./dist/lib/xen-orchestra-*.d.mts"
|
||||
},
|
||||
"./lib/complex-matcher": {
|
||||
"default": "./dist/lib/complex-matcher.mjs",
|
||||
"type": "./dist/lib/complex-matcher.d.mts"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"typescript": "~5.6.3",
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"@types/express": "^5.0.0"
|
||||
"typescript-eslint": "^8.23.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { XoHost, XoNetwork, XoVif } from './xo.mjs'
|
||||
import type { XoHost } from './xo.mjs'
|
||||
|
||||
declare const __brand: unique symbol
|
||||
|
||||
@@ -655,14 +655,6 @@ export const BACKUP_TYPE = { backup: 'backup', metadata: 'metadataBackup', mirro
|
||||
|
||||
export type BACKUP_TYPE = (typeof BACKUP_TYPE)[keyof typeof BACKUP_TYPE]
|
||||
|
||||
export const SUPPORTED_VDI_FORMAT = {
|
||||
raw: 'raw',
|
||||
vhd: 'vhd',
|
||||
qcow2: 'qcow2',
|
||||
} as const
|
||||
|
||||
export type SUPPORTED_VDI_FORMAT = (typeof SUPPORTED_VDI_FORMAT)[keyof typeof SUPPORTED_VDI_FORMAT]
|
||||
|
||||
// ----- XAPI Stats
|
||||
|
||||
type XapiStatsResponse<T> = {
|
||||
@@ -740,32 +732,3 @@ export type XapiVmStatsRaw = {
|
||||
export type XapiVmStats = XapiStatsResponse<XapiVmStatsRaw>
|
||||
|
||||
export type XapiPoolStats = Record<XoHost['id'], XapiHostStats | { error: Record<string, unknown> }>
|
||||
|
||||
export type TrafficRuleProtocol = (typeof TRAFFIC_RULE_PROTOCOLS)[number]
|
||||
|
||||
export const SDN_CONTROLLER_OF_RULES_KEY = 'xo:sdn-controller:of-rules'
|
||||
export const SDN_CONTROLLER_OF_METHOD_KEY = 'xo:sdn-controller:of-method'
|
||||
export const SDN_CONTROLLER_OF_FORMAT_KEY = 'xo:sdn-controller:of-format'
|
||||
export const TRAFFIC_RULE_PROTOCOLS = ['ARP', 'ICMP', 'IP', 'TCP', 'UDP'] as const
|
||||
export const TRAFFIC_RULE_PROTOCOLS_WITH_PORT: readonly TrafficRuleProtocol[] = ['TCP', 'UDP']
|
||||
|
||||
export type TrafficRuleTargetType = XoNetwork['type'] | XoVif['type']
|
||||
|
||||
export type TrafficRuleDirection = 'from' | 'to' | 'from/to'
|
||||
|
||||
export type RawTrafficRule = {
|
||||
allow: boolean
|
||||
protocol: string
|
||||
ipRange: string
|
||||
direction: string
|
||||
port?: string
|
||||
}
|
||||
|
||||
type BaseTrafficRule = RawTrafficRule & {
|
||||
id: string
|
||||
networkId: XoNetwork['id']
|
||||
}
|
||||
|
||||
export type TrafficRule =
|
||||
| (BaseTrafficRule & { type: XoVif['type']; sourceId: XoVif['id'] })
|
||||
| (BaseTrafficRule & { type: XoNetwork['type']; sourceId: XoNetwork['id'] })
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export * from './common.mjs'
|
||||
export * from './xen-api.mjs'
|
||||
export * from './xo.mjs'
|
||||
export * from './xo-app.mjs'
|
||||
export * from './lib/xen-orchestra-xapi.mjs'
|
||||
export * from './lib/xen-orchestra-acl.mjs'
|
||||
export * from './lib/complex-matcher.mjs'
|
||||
export * from './lib/rest-api.mjs'
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export type Or<T> = Id<{ __or: T[] }>
|
||||
|
||||
export type Id<T> = { id: T }
|
||||
|
||||
export type IdOr<T> = Id<T> | Or<T>
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import type { VatesTask } from './vates-task.mjs'
|
||||
import type { XoApp } from '../xo-app.mjs'
|
||||
|
||||
export type SecurityName = '*' | 'token' | 'basic' | 'none'
|
||||
|
||||
export type FieldDefinition =
|
||||
| {
|
||||
type: 'string'
|
||||
example?: string
|
||||
optional?: boolean
|
||||
}
|
||||
| {
|
||||
type: 'boolean'
|
||||
example?: boolean
|
||||
optional?: boolean
|
||||
}
|
||||
| {
|
||||
type: 'number'
|
||||
example?: number
|
||||
optional?: boolean
|
||||
}
|
||||
| {
|
||||
type: 'enum'
|
||||
enum: string[]
|
||||
example?: string
|
||||
optional?: boolean
|
||||
}
|
||||
| {
|
||||
type: 'object'
|
||||
fields: Record<string, FieldDefinition>
|
||||
optional?: boolean
|
||||
}
|
||||
| {
|
||||
type: 'array'
|
||||
items: FieldDefinition
|
||||
example?: unknown[]
|
||||
optional?: boolean
|
||||
}
|
||||
|
||||
export type ParamFieldDefinition = Exclude<
|
||||
FieldDefinition,
|
||||
{ type: 'boolean' } | { type: 'object' } | { type: 'array' }
|
||||
>
|
||||
|
||||
export type QueryFieldDefinition = Exclude<FieldDefinition, { type: 'object' } | { type: 'array' }>
|
||||
type RestApi = object & { xoApp: XoApp }
|
||||
export type CreateAction = <Result>(
|
||||
callback: (task: VatesTask) => MaybePromise<Result>,
|
||||
opts: {
|
||||
sync?: boolean
|
||||
statusCode?: number
|
||||
taskProperties: { name: string; [key: string]: unknown }
|
||||
}
|
||||
) => Promise<Result | undefined>
|
||||
|
||||
export interface BaseRouteDefinition<Middleware> {
|
||||
method: 'get' | 'post' | 'put' | 'delete' | 'patch'
|
||||
endpoint: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
params?: Record<string, ParamFieldDefinition>
|
||||
query?: Record<string, QueryFieldDefinition>
|
||||
body?: Record<string, FieldDefinition>
|
||||
responses?: Array<{
|
||||
status: number
|
||||
description: string
|
||||
schema?: Record<string, FieldDefinition>
|
||||
}>
|
||||
middlewares?: Middleware[]
|
||||
scope?: 'acl'
|
||||
callback: (params: {
|
||||
req: Request
|
||||
res: Response
|
||||
next: NextFunction
|
||||
restApi: RestApi
|
||||
createAction: CreateAction
|
||||
}) => MaybePromise<unknown>
|
||||
security?: SecurityName
|
||||
}
|
||||
|
||||
export type MiddlewareDescriptor =
|
||||
| { name: 'json' | 'urlencoded' | 'text' | 'raw'; options?: Record<string, unknown> }
|
||||
| { name: 'acl'; acls: LooseAclEntry | LooseAclEntry[] }
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>
|
||||
/**
|
||||
* Loosely-typed mirror of {@link https://github.com/vatesfr/xen-orchestra/blob/master/@xen-orchestra/rest-api/src/middlewares/acl.middleware.mts | AclEntry}
|
||||
* from `@xen-orchestra/rest-api/src/middlewares/acl.middleware.mts`.
|
||||
*/
|
||||
export type LooseAclEntry = {
|
||||
resource: string
|
||||
action?: string | ((opts: { req: object; restApi: RestApi }) => string | undefined)
|
||||
actions?: string[] | ((opts: { req: object; restApi: RestApi }) => string[])
|
||||
objectId?: string | ((opts: { req: object; restApi: RestApi }) => string)
|
||||
objectIds?: string[] | ((opts: { req: object; restApi: RestApi }) => string[])
|
||||
object?: object | ((opts: { req: object; restApi: RestApi }) => MaybePromise<object> | undefined)
|
||||
objects?: object[] | ((opts: { req: object; restApi: RestApi }) => MaybePromise<object[]> | undefined)
|
||||
getObject?: (opts: { restApi: RestApi }) => (id: string) => MaybePromise<object>
|
||||
}
|
||||
|
||||
export type PluginRestRouteDefinition = BaseRouteDefinition<MiddlewareDescriptor>
|
||||
@@ -1,21 +1,7 @@
|
||||
import { Branded } from '../common.mjs'
|
||||
|
||||
export interface VatesTask {
|
||||
_abortController: AbortController
|
||||
_onProgress(data?: unknown): void
|
||||
export type Task = {
|
||||
id: Branded<'task'>
|
||||
|
||||
id: Branded<'vates-task'>
|
||||
status: 'failure' | 'pending' | 'success'
|
||||
|
||||
abort(reason?: unknown): void
|
||||
failure(error?: unknown): void
|
||||
info(message: string, data: unknown): void
|
||||
run<T>(fn: () => T | (() => Promise<T>)): Promise<T>
|
||||
runInside<T>(fn: () => T | (() => Promise<T>)): Promise<T>
|
||||
set(name: string, value: unknown): void
|
||||
start(): void
|
||||
success(result?: unknown): void
|
||||
warning(message: string, data: unknown): void
|
||||
wrap<T>(fn: () => T | (() => Promise<T>)): Promise<T>
|
||||
wrapInside<T>(fn: () => T | (() => Promise<T>)): Promise<T>
|
||||
run: <T>(fn: () => T) => Promise<T>
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Branded } from '../common.mjs'
|
||||
import type { XoGroup, XoUser } from '../xo.mjs'
|
||||
|
||||
type XoAclBaseRole = {
|
||||
id: Branded<'acl-v2-role'>
|
||||
name: string
|
||||
description?: string
|
||||
privilegeIds: XoAclBasePrivilege['id'][]
|
||||
}
|
||||
|
||||
type XoAclRegularRole = XoAclBaseRole & {
|
||||
groupIds: XoGroup['id'][]
|
||||
userIds: XoUser['id'][]
|
||||
}
|
||||
|
||||
type XoAclTemplateRole = XoAclBaseRole & {
|
||||
isTemplate: true
|
||||
roleTemplateId: number
|
||||
}
|
||||
|
||||
export type XoAclRole = XoAclRegularRole | XoAclTemplateRole
|
||||
|
||||
export type XoAclSupportedActionsByResource = {
|
||||
[resource: string]: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* E.g
|
||||
* vms {
|
||||
* shutdown: {
|
||||
* clean: true,
|
||||
* hard: true,
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* `shutdown | shutdown:clean | shutdown:hard`
|
||||
*/
|
||||
export type GetKeysRecursively<T, Prefix extends string = ''> = {
|
||||
[K in keyof T]: T[K] extends object
|
||||
? K extends string
|
||||
? `${Prefix}${K}` | GetKeysRecursively<T[K], `${Prefix}${K}:`>
|
||||
: never
|
||||
: K extends string
|
||||
? `${Prefix}${K}`
|
||||
: never
|
||||
}[keyof T]
|
||||
|
||||
export type XoAclSupportedResource<TActionsByResource extends XoAclSupportedActionsByResource> =
|
||||
keyof TActionsByResource
|
||||
|
||||
export type XoAclSupportedActions<
|
||||
TActionsByResource extends XoAclSupportedActionsByResource,
|
||||
TResource extends XoAclSupportedResource<TActionsByResource>,
|
||||
> = (GetKeysRecursively<TActionsByResource[TResource]> & string) | '*'
|
||||
|
||||
export type XoAclBasePrivilege = {
|
||||
id: Branded<'acl-v2-privilege'>
|
||||
resource: string
|
||||
action: string
|
||||
selector?: string
|
||||
effect: 'allow' | 'deny'
|
||||
roleId: XoAclRole['id']
|
||||
}
|
||||
|
||||
export type XoAclPrivilege<
|
||||
TActionsByResource extends XoAclSupportedActionsByResource,
|
||||
TResource extends XoAclSupportedResource<TActionsByResource>,
|
||||
> = XoAclBasePrivilege & {
|
||||
resource: TResource
|
||||
action: XoAclSupportedActions<TActionsByResource, TResource>
|
||||
}
|
||||
|
||||
export type XoUserRole = {
|
||||
id: string
|
||||
roleId: XoAclRole['id']
|
||||
userId: XoUser['id']
|
||||
}
|
||||
|
||||
export type XoGroupRole = {
|
||||
id: string
|
||||
roleId: XoAclRole['id']
|
||||
groupId: XoGroup['id']
|
||||
}
|
||||
@@ -1,104 +1,28 @@
|
||||
import {
|
||||
WrappedXenApiRecord,
|
||||
XenApiHost,
|
||||
XenApiHostWrapped,
|
||||
XenApiNetwork,
|
||||
XenApiNetworkWrapped,
|
||||
XenApiRecord,
|
||||
XenApiSr,
|
||||
XenApiTask,
|
||||
XenApiVbd,
|
||||
XenApiVdi,
|
||||
XenApiVif,
|
||||
XenApiVm,
|
||||
XenApiVmWrapped,
|
||||
XenApiVtpm,
|
||||
} from '../xen-api.mjs'
|
||||
import type { Readable } from 'node:stream'
|
||||
import type {
|
||||
BOND_MODE,
|
||||
OPAQUE_REF_NULL,
|
||||
SUPPORTED_VDI_FORMAT,
|
||||
VBD_MODE,
|
||||
VBD_TYPE,
|
||||
VDI_TYPE,
|
||||
VIF_LOCKING_MODE,
|
||||
VM_OPERATIONS,
|
||||
} from '../common.mjs'
|
||||
import type { PassThrough, Readable } from 'node:stream'
|
||||
import type {
|
||||
XapiXoRecord,
|
||||
XoGpuGroup,
|
||||
XoVgpuType,
|
||||
XoHost,
|
||||
XoNetwork,
|
||||
XoPif,
|
||||
XoSr,
|
||||
XoUser,
|
||||
XoVdi,
|
||||
XoVgpuType,
|
||||
XoVm,
|
||||
XoVmTemplate,
|
||||
XoVif,
|
||||
XoVmSnapshot,
|
||||
} from '../xo.mjs'
|
||||
|
||||
/**
|
||||
* Properties accepted by {@link Xapi.editVm}.
|
||||
*
|
||||
* Field names match the canonical (camelCase) properties defined by the
|
||||
* underlying `_editVm` (via `makeEditObject`). `_editVm` still resolves the
|
||||
* snake_case and legacy aliases, but the canonical names are used here.
|
||||
*/
|
||||
export interface EditVmProps {
|
||||
affinityHost?: string | null
|
||||
autoPoweron?: boolean
|
||||
blockedOperations?: Partial<Record<VM_OPERATIONS, boolean | string | null>>
|
||||
coresPerSocket?: number | string | null
|
||||
cpuCap?: number | null
|
||||
cpuMask?: number[]
|
||||
cpuWeight?: number | null
|
||||
cpus?: number
|
||||
cpusStaticMax?: number | string
|
||||
/**
|
||||
* Update VM creation metadata stored under `other_config.xo:*`. The object is
|
||||
* merged with the existing data.
|
||||
*/
|
||||
creation?: { user?: string }
|
||||
expNestedHvm?: boolean
|
||||
hasVendorDevice?: boolean
|
||||
highAvailability?: 'best-effort' | 'restart' | ''
|
||||
hvmBootFirmware?: string | null
|
||||
memory?: number | string
|
||||
memoryMax?: number | string
|
||||
memoryMin?: number | string
|
||||
memoryStaticMax?: number | string
|
||||
nameDescription?: string
|
||||
nameLabel?: string
|
||||
nestedVirt?: boolean
|
||||
nicType?: string | null
|
||||
notes?: string | null
|
||||
PV_args?: string
|
||||
secureBoot?: boolean
|
||||
startDelay?: number
|
||||
suspendSr?: string | null
|
||||
tags?: string[]
|
||||
uefiMode?: 'setup' | 'user'
|
||||
vga?: 'std' | 'cirrus'
|
||||
videoram?: 1 | 2 | 4 | 8 | 16
|
||||
viridian?: boolean
|
||||
virtualizationMode?: 'pv' | 'hvm'
|
||||
xenStoreData?: Record<string, string | null>
|
||||
}
|
||||
|
||||
/** Properties accepted by {@link Xapi.editVif}. */
|
||||
export interface EditVifProps {
|
||||
ipv4Allowed?: string[]
|
||||
ipv6Allowed?: string[]
|
||||
lockingMode?: VIF_LOCKING_MODE
|
||||
// in kB/s, `null` removes the limit
|
||||
rateLimit?: number | null
|
||||
txChecksumming?: boolean
|
||||
}
|
||||
|
||||
export type XcpPatches = {
|
||||
changelog?: {
|
||||
author: string
|
||||
@@ -129,29 +53,14 @@ 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>
|
||||
|
||||
barrier<T extends XenApiRecord>(ref: T['$ref']): Promise<Extract<WrappedXenApiRecord, { $ref: T['$ref'] }>>
|
||||
connectVif(vifId: XoVif['id']): Promise<void>
|
||||
getField<T extends XenApiRecord, K extends keyof T>(
|
||||
type: Extract<WrappedXenApiRecord, T>['$type'],
|
||||
ref: T['$ref'],
|
||||
field: K
|
||||
): Promise<T[K]>
|
||||
getObject: <
|
||||
XoRecord extends XapiXoRecord,
|
||||
WrappedRecord extends WrappedXenApiRecord = Extract<WrappedXenApiRecord, { $type: XoRecord['type'] }>,
|
||||
>(
|
||||
idOrUuidOrRef: XoRecord['id'] | WrappedRecord['$ref'] | WrappedRecord['uuid'],
|
||||
defaultValue?: WrappedRecord
|
||||
) => WrappedRecord
|
||||
createNetwork(
|
||||
params:
|
||||
| {
|
||||
@@ -168,125 +77,11 @@ export interface Xapi {
|
||||
vlan: number
|
||||
}
|
||||
): Promise<XenApiNetworkWrapped>
|
||||
createBondedNetwork(params: {
|
||||
bondMode: BOND_MODE
|
||||
pifIds: XoPif['id'][]
|
||||
name: string
|
||||
description?: string
|
||||
mtu?: number
|
||||
}): Promise<XenApiNetworkWrapped>
|
||||
deleteNetwork(id: XoNetwork['id']): Promise<void>
|
||||
deleteVif(vifId: XoVif['id']): Promise<void>
|
||||
disconnectVif(vifId: XoVif['id']): Promise<void>
|
||||
exportVmOva(vmRef: XenApiVm['$ref']): Promise<PassThrough>
|
||||
emergencyShutdownHost(hostId: XoHost['id']): Promise<void>
|
||||
ejectHostFromPool(hostId: XoHost['id']): Promise<void>
|
||||
host_smartReboot(
|
||||
hostRef: XenApiHost['$ref'],
|
||||
bypassBlockedSuspend?: boolean,
|
||||
bypassCurrentVmCheck?: boolean
|
||||
): Promise<void>
|
||||
migrateVm(
|
||||
vmId: XoVm['id'],
|
||||
hostXapi: Xapi,
|
||||
hostId: XoHost['id'],
|
||||
opts?: {
|
||||
force?: boolean
|
||||
mapVdisSrs?: Record<XoVdi['id'], XoSr['id']>
|
||||
mapVifsNetworks?: Record<XoVif['id'], XoNetwork['id']>
|
||||
migrationNetworkId?: XoNetwork['id']
|
||||
sr?: XoSr['id']
|
||||
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>
|
||||
revertVm(snapshotId: XoVmSnapshot['id']): Promise<void>
|
||||
unpauseVm(id: XoVm['id']): Promise<void>
|
||||
cloneVm(
|
||||
vmId: XoVm['id'],
|
||||
opts?: {
|
||||
nameLabel?: string
|
||||
fast?: boolean
|
||||
}
|
||||
): Promise<XenApiVmWrapped>
|
||||
copyVm(
|
||||
vmId: XoVm['id'],
|
||||
opts?: {
|
||||
nameLabel?: string
|
||||
srOrSrId?: XoSr['id'] | XoSr
|
||||
}
|
||||
): Promise<XenApiVmWrapped>
|
||||
remoteCopyVm(
|
||||
vmId: XoVm['id'],
|
||||
targetXapi: Xapi,
|
||||
targetSrId: XoSr['id'],
|
||||
opts?: {
|
||||
compress?: 'zstd' | 'gzip' | boolean
|
||||
nameLabel?: string
|
||||
}
|
||||
): Promise<{ vm: XenApiVmWrapped }>
|
||||
forgetSr(id: XoSr['id']): Promise<void>
|
||||
forgetHost(hostId: XoHost['id']): Promise<void>
|
||||
powerOnHost(hostId: XoHost['id']): Promise<void>
|
||||
rebootHost(hostId: XoHost['id'], force?: boolean): Promise<void>
|
||||
shutdownHost(hostId: XoHost['id'], opts?: { force?: boolean; bypassEvacuate?: boolean }): Promise<void>
|
||||
SR_create(params: {
|
||||
content_type?: XoSr['content_type']
|
||||
// index signature, not `Record<string, string>`: tsoa emits an empty schema for `Record`
|
||||
// (https://github.com/lukeautry/tsoa/pull/1858). device_config keys are driver-specific (SR_type).
|
||||
device_config: {
|
||||
chapuser?: string
|
||||
chappassword?: string
|
||||
device?: string
|
||||
legacy_mode?: string
|
||||
location?: string
|
||||
nfsversion?: string
|
||||
options?: string
|
||||
password?: string
|
||||
SCSIid?: string
|
||||
server?: string
|
||||
serverpath?: string
|
||||
target?: string
|
||||
targetIQN?: string
|
||||
username?: string
|
||||
} & {
|
||||
[key: string]: string
|
||||
}
|
||||
host: XenApiHost['$ref']
|
||||
name_description?: XoSr['name_description']
|
||||
name_label: XoSr['name_label']
|
||||
physical_size?: number
|
||||
shared: boolean
|
||||
sm_config?: { [key: string]: string }
|
||||
type: XoSr['SR_type']
|
||||
}): Promise<XenApiSr['$ref']>
|
||||
SR_importVdi(
|
||||
ref: XenApiSr['$ref'],
|
||||
stream: Readable,
|
||||
opts?: {
|
||||
format?: SUPPORTED_VDI_FORMAT
|
||||
name_description?: XoVdi['name_description']
|
||||
name_label?: XoVdi['name_label']
|
||||
other_config?: XoVdi['other_config']
|
||||
read_only?: boolean
|
||||
sharable?: boolean
|
||||
SR?: XenApiSr['$ref']
|
||||
tags?: XoVdi['tags']
|
||||
type?: XoVdi['type']
|
||||
virtual_size?: XoVdi['size']
|
||||
xenstore_data?: Record<string, string>
|
||||
}
|
||||
): Promise<XenApiVdi['$ref']>
|
||||
SR_reclaimSpace(ref: XenApiSr['$ref']): Promise<void>
|
||||
editVm(
|
||||
id: XoVm['id'],
|
||||
props: EditVmProps,
|
||||
checkLimits?: (limits: Record<string, number>, vm: XenApiVmWrapped) => Promise<void>
|
||||
): Promise<void>
|
||||
editVif(id: XoVif['id'], props: EditVifProps): Promise<void>
|
||||
startVm(
|
||||
id: XoVm['id'],
|
||||
opts?: {
|
||||
@@ -302,10 +97,6 @@ export interface Xapi {
|
||||
startOnly?: boolean
|
||||
}
|
||||
): Promise<void>
|
||||
VM_export(
|
||||
vmRef: XenApiVm['$ref'],
|
||||
opts?: { cancelToken?: unknown; compress?: boolean | 'zstd' | 'gzip'; useSnapshot?: boolean }
|
||||
): ReturnType<Xapi['getResource']>
|
||||
VM_import(
|
||||
stream: Readable,
|
||||
srRef?: XenApiSr['$ref'],
|
||||
@@ -316,7 +107,6 @@ export interface Xapi {
|
||||
metadataVm: {
|
||||
affinityHost?: XoHost['id']
|
||||
autoPoweron?: boolean
|
||||
cpus?: number
|
||||
memory?: number
|
||||
name_description?: string
|
||||
name_label: string
|
||||
@@ -358,80 +148,12 @@ export interface Xapi {
|
||||
vgpuType?: XoVgpuType['id']
|
||||
gpuGroup?: XoGpuGroup['id']
|
||||
copyHostBiosStrings?: boolean
|
||||
highAvailability?: XoVm['high_availability']
|
||||
hvmBootFirmware?: 'uefi' | 'bios'
|
||||
secureBoot?: boolean
|
||||
},
|
||||
checkLimits?: boolean,
|
||||
creatorId?: XoUser['id'],
|
||||
opts?: { destroyAllVifs: boolean }
|
||||
): Promise<XenApiVmWrapped>
|
||||
moveVdi(
|
||||
vdiId: XenApiVdi['$ref'] | XoVdi['id'],
|
||||
srId: XenApiSr['$ref'] | XoSr['id'],
|
||||
opts?: { _failOnCbtError?: boolean }
|
||||
): Promise<XenApiVdi>
|
||||
VBD_create(params: {
|
||||
bootable?: boolean
|
||||
empty?: boolean
|
||||
mode?: VBD_MODE
|
||||
other_config?: Record<string, string>
|
||||
qos_algorithm_params?: Record<string, string>
|
||||
qos_algorithm_type?: string
|
||||
type?: VBD_TYPE
|
||||
unpluggable?: boolean
|
||||
userdevice?: string
|
||||
VDI: XenApiVdi['$ref'] | OPAQUE_REF_NULL
|
||||
VM: XenApiVm['$ref']
|
||||
}): Promise<XenApiVbd['$ref']>
|
||||
VBD_destroy(vbdRef: XenApiVbd['$ref']): Promise<void>
|
||||
VBD_unplug(vbdRef: XenApiVbd['$ref']): Promise<void>
|
||||
VDI_create(
|
||||
options: {
|
||||
name_description?: XoVdi['name_description']
|
||||
name_label?: XoVdi['name_label']
|
||||
other_config?: XoVdi['other_config']
|
||||
read_only?: boolean
|
||||
sharable?: boolean
|
||||
SR?: XenApiSr['$ref']
|
||||
tags?: XoVdi['tags']
|
||||
type?: VDI_TYPE
|
||||
virtual_size: XoVdi['size']
|
||||
xenstore_data?: Record<string, string>
|
||||
},
|
||||
extraOptions?: {
|
||||
sm_config?: Record<string, string>
|
||||
}
|
||||
): Promise<XenApiVdi['$ref']>
|
||||
VDI_destroy(vdiRef: XenApiVdi['$ref']): Promise<void>
|
||||
VDI_destroyCloudInitConfig(vdiRef: XenApiVdi['$ref'], opts?: { timeLimit?: number }): Promise<void>
|
||||
VDI_exportContent(
|
||||
vdiRef: XenApiVdi['$ref'],
|
||||
opts: { baseRef?: string; cancelToken?: unknown; format: SUPPORTED_VDI_FORMAT }
|
||||
): Promise<Readable & { length?: number }>
|
||||
VDI_importContent(
|
||||
vdiRef: XenApiVdi['$ref'],
|
||||
stream: Readable | Buffer,
|
||||
opts: { cancelToken?: unknown; format: SUPPORTED_VDI_FORMAT }
|
||||
): Promise<void>
|
||||
VIF_create(
|
||||
options: {
|
||||
currently_attached?: boolean
|
||||
device?: string
|
||||
ipv4_allowed?: string[]
|
||||
ipv6_allowed?: string[]
|
||||
locking_mode?: VIF_LOCKING_MODE
|
||||
MTU?: number
|
||||
network: XenApiNetwork['$ref']
|
||||
other_config?: Record<string, string>
|
||||
qos_algorithm_params?: Record<string, string>
|
||||
qos_algorithm_type?: string
|
||||
VM: XenApiVm['$ref']
|
||||
},
|
||||
extraOptions?: {
|
||||
MAC?: string
|
||||
}
|
||||
): Promise<XenApiVif['$ref']>
|
||||
VM_createCloudInitConfig(
|
||||
vmRef: XenApiVm['$ref'],
|
||||
cloudConfig: string,
|
||||
@@ -450,29 +172,5 @@ export interface Xapi {
|
||||
pathname: string,
|
||||
params?: { host?: XenApiHost; query?: Record<string, unknown>; task?: boolean | XenApiTask['$ref'] }
|
||||
): Promise<{ body: Readable }>
|
||||
clearHost(
|
||||
host: Pick<XenApiHostWrapped, '$ref' | '$pool'>,
|
||||
force?: boolean,
|
||||
opts?: { transient?: boolean }
|
||||
): Promise<void>
|
||||
disableHost(hostId: XoHost['id'], opts?: { transient?: boolean }): Promise<void>
|
||||
enableHost(hostId: XoHost['id']): Promise<void>
|
||||
getRecordByUuid<
|
||||
Type extends WrappedXenApiRecord['$type'],
|
||||
XenApiRecord extends WrappedXenApiRecord = Extract<WrappedXenApiRecord, { $type: Type }>,
|
||||
>(
|
||||
type: Type,
|
||||
uuid: XenApiRecord['uuid']
|
||||
): Promise<XenApiRecord>
|
||||
isHyperThreadingEnabled(hostId: XoHost['id']): Promise<boolean | null>
|
||||
VTPM_create(params: { VM: XenApiVm['$ref']; is_unique?: boolean; contents?: string }): Promise<XenApiVtpm['$ref']>
|
||||
destroySr(id: XoSr['id']): Promise<void>
|
||||
xostor_delete(ref: XenApiSr['$ref']): Promise<void>
|
||||
objects: {
|
||||
indexes: {
|
||||
type: {
|
||||
[XenApiRecord in WrappedXenApiRecord as XenApiRecord['$type']]: Record<XenApiRecord['uuid'], XenApiRecord>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +70,6 @@ import type { Xapi } from './lib/xen-orchestra-xapi.mjs'
|
||||
// https://github.com/vatesfr/xen-orchestra/tree/wip-xapi-types-generator/%40xen-orchestra/xapi-generator
|
||||
// All properties added after XenServer 7.0 (dundee) release have been marked as optional
|
||||
|
||||
type TagCallMethods = {
|
||||
(method: 'add_tags', tag: string): Promise<void>
|
||||
(method: 'remove_tags', tag: string): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Add properties injected by `xen-api`.
|
||||
* $ref property is also injected by XOLite, so she is not present here.
|
||||
@@ -82,7 +77,6 @@ type TagCallMethods = {
|
||||
type WrapperXenApi<T, Type extends string, Fn = { (): void }> = T & {
|
||||
$call: Fn
|
||||
$callAsync: Fn
|
||||
$pool: XenApiPool
|
||||
$type: Type
|
||||
$snapshot(params: {
|
||||
cancelToken?: unknown
|
||||
@@ -168,8 +162,6 @@ export interface XenApiEvent {
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
type XenApiPoolCallMethods = TagCallMethods & {}
|
||||
|
||||
export interface XenApiPool {
|
||||
$ref: Branded<'pool'>
|
||||
allowed_operations: POOL_ALLOWED_OPERATIONS[]
|
||||
@@ -192,7 +184,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
|
||||
@@ -233,7 +224,7 @@ export interface XenApiPool {
|
||||
/** @deprecated */
|
||||
wlb_verify_cert?: boolean
|
||||
}
|
||||
export type XenApiPoolWrapped = WrapperXenApi<XenApiPool, 'pool', XenApiPoolCallMethods>
|
||||
export type XenApiPoolWrapped = WrapperXenApi<XenApiPool, 'pool'>
|
||||
|
||||
/** @deprecated */
|
||||
export interface XenApiPoolPatch {
|
||||
@@ -265,7 +256,7 @@ export interface XenApiPoolUpdate {
|
||||
version?: string
|
||||
}
|
||||
|
||||
type XenApiVmCallMethods = TagCallMethods & {
|
||||
type XenApiVmCallMethods = {
|
||||
(method: 'start', start_paused: boolean, force: boolean): Promise<void>
|
||||
(method: 'clean_shutdown'): Promise<void>
|
||||
(method: 'hard_shutdown'): Promise<void>
|
||||
@@ -492,9 +483,6 @@ export interface XenApiDrTask {
|
||||
uuid: string
|
||||
}
|
||||
|
||||
type XenApiHostCallMethods = TagCallMethods & {
|
||||
<T>(method: 'call_plugin', plugin: string, fn: string, args: Record<string, string>): Promise<T>
|
||||
}
|
||||
export interface XenApiHost {
|
||||
$ref: Branded<'host'>
|
||||
address: string
|
||||
@@ -571,9 +559,7 @@ export interface XenApiHost {
|
||||
uuid: string
|
||||
virtual_hardware_platform_versions: number[]
|
||||
}
|
||||
export interface XenApiHostWrapped extends WrapperXenApi<XenApiHost, 'host', XenApiHostCallMethods> {
|
||||
$restartAgent(): Promise<void>
|
||||
}
|
||||
export type XenApiHostWrapped = WrapperXenApi<XenApiHost, 'host'>
|
||||
|
||||
export interface XenApiHostCrashdump {
|
||||
$ref: Branded<'host_crashdump'>
|
||||
@@ -628,10 +614,6 @@ export interface XenApiHostCpu {
|
||||
vendor: string
|
||||
}
|
||||
|
||||
type XenApiNetworkCallMethods = TagCallMethods & {
|
||||
(method: 'add_purpose', purpose: NETWORK_PURPOSE): Promise<void>
|
||||
}
|
||||
|
||||
export interface XenApiNetwork {
|
||||
$ref: Branded<'network'>
|
||||
allowed_operations: NETWORK_OPERATIONS[]
|
||||
@@ -651,7 +633,8 @@ export interface XenApiNetwork {
|
||||
uuid: string
|
||||
VIFs: XenApiVif['$ref'][]
|
||||
}
|
||||
export interface XenApiNetworkWrapped extends WrapperXenApi<XenApiNetwork, 'network', XenApiNetworkCallMethods> {}
|
||||
export interface XenApiNetworkWrapped extends WrapperXenApi<XenApiNetwork, 'network'> {}
|
||||
|
||||
export interface XenApiVif {
|
||||
$ref: Branded<'VIF'>
|
||||
allowed_operations: VIF_OPERATIONS[]
|
||||
@@ -799,7 +782,6 @@ export interface XenApiSm {
|
||||
}
|
||||
export type XenApiSmWrapped = WrapperXenApi<XenApiSm, 'SM'>
|
||||
|
||||
type XenApiSrCallMethods = TagCallMethods & {}
|
||||
export interface XenApiSr {
|
||||
$ref: Branded<'SR'>
|
||||
allowed_operations: STORAGE_OPERATIONS[]
|
||||
@@ -824,7 +806,7 @@ export interface XenApiSr {
|
||||
VDIs: XenApiVdi['$ref'][]
|
||||
virtual_allocation: number
|
||||
}
|
||||
export type XenApiSrWrapped = WrapperXenApi<XenApiSr, 'SR', XenApiSrCallMethods>
|
||||
export type XenApiSrWrapped = WrapperXenApi<XenApiSr, 'SR'>
|
||||
|
||||
export interface XenApiSrStat {
|
||||
$ref: Branded<'sr_stat'>
|
||||
@@ -850,8 +832,6 @@ export interface XenApiLvhd {
|
||||
uuid: string
|
||||
}
|
||||
|
||||
type XenApiVdiCallMethods = TagCallMethods & {}
|
||||
|
||||
export interface XenApiVdi {
|
||||
$ref: Branded<'VDI'>
|
||||
allow_caching: boolean
|
||||
@@ -888,7 +868,7 @@ export interface XenApiVdi {
|
||||
virtual_size: number
|
||||
xenstore_data: Record<string, string>
|
||||
}
|
||||
export type XenApiVdiWrapped = WrapperXenApi<XenApiVdi, 'VDI', XenApiVdiCallMethods>
|
||||
export type XenApiVdiWrapped = WrapperXenApi<XenApiVdi, 'VDI'>
|
||||
|
||||
export interface XenApiVbd {
|
||||
$ref: Branded<'VBD'>
|
||||
@@ -941,7 +921,6 @@ export interface XenApiPbd {
|
||||
SR: XenApiSr['$ref']
|
||||
uuid: string
|
||||
}
|
||||
export type XenApiPbdWrapped = WrapperXenApi<XenApiPbd, 'PBD'>
|
||||
|
||||
/** @deprecated */
|
||||
export interface XenApiCrashdump {
|
||||
@@ -1016,7 +995,6 @@ export interface XenApiMessage {
|
||||
timestamp: string
|
||||
uuid: string
|
||||
}
|
||||
export type XenApiMessageWrapped = WrapperXenApi<XenApiMessage, 'message'>
|
||||
|
||||
export interface XenApiSecret {
|
||||
$ref: Branded<'secret'>
|
||||
@@ -1379,7 +1357,6 @@ export type XenApiRecord =
|
||||
export type WrappedXenApiRecord =
|
||||
| XenApiHostWrapped
|
||||
| XenApiNetworkWrapped
|
||||
| XenApiPbdWrapped
|
||||
| XenApiPifWrapped
|
||||
| XenApiPoolWrapped
|
||||
| XenApiSrWrapped
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
import { EventEmitter } from 'node:stream'
|
||||
import type {
|
||||
AnyXoJob,
|
||||
AnyXoLog,
|
||||
XapiXoRecord,
|
||||
XoAuthenticationToken,
|
||||
XoBackupRepository,
|
||||
XoConfigBackupArchive,
|
||||
XoGroup,
|
||||
XoHost,
|
||||
XoPool,
|
||||
XoPoolBackupArchive,
|
||||
XoProxy,
|
||||
XoSchedule,
|
||||
XoServer,
|
||||
XoTask,
|
||||
XoUser,
|
||||
XoVif,
|
||||
XoVm,
|
||||
XoVmBackupArchive,
|
||||
} from './xo.mjs'
|
||||
import { VatesTask } from './lib/vates-task.mjs'
|
||||
import type { PluginRestRouteDefinition } from './lib/rest-api.mjs'
|
||||
import {
|
||||
Xapi,
|
||||
XapiHostStats,
|
||||
XapiPoolStats,
|
||||
XapiStatsGranularity,
|
||||
XapiVmStats,
|
||||
XenApiGpuGroupWrapped,
|
||||
XenApiHostWrapped,
|
||||
XenApiMessageWrapped,
|
||||
XenApiNetworkWrapped,
|
||||
XenApiPbdWrapped,
|
||||
XenApiPciWrapped,
|
||||
XenApiPgpuWrapped,
|
||||
XenApiPifWrapped,
|
||||
XenApiPoolWrapped,
|
||||
XenApiSmWrapped,
|
||||
XenApiSrWrapped,
|
||||
XenApiVbdWrapped,
|
||||
XenApiVdiWrapped,
|
||||
XenApiVgpuTypeWrapped,
|
||||
XenApiVgpuWrapped,
|
||||
XenApiVifWrapped,
|
||||
XenApiVmWrapped,
|
||||
XenApiVtpmWrapped,
|
||||
XoAclBasePrivilege,
|
||||
XoAclRole,
|
||||
XoGroupRole,
|
||||
XoUserRole,
|
||||
} from './index.mjs'
|
||||
|
||||
type FeatureCode =
|
||||
| 'BACKUP.DELTA'
|
||||
| 'BACKUP.DELTA_REPLICATION'
|
||||
| 'BACKUP.FULL'
|
||||
| 'BACKUP.HEALTHCHECK'
|
||||
| 'BACKUP.METADATA'
|
||||
| 'BACKUP.MIRROR'
|
||||
| 'BACKUP.WITH_RAM'
|
||||
| 'BACKUP.SMART_BACKUP'
|
||||
| 'BACKUP.S3'
|
||||
| 'DOCKER'
|
||||
| 'EXPORT.XVA'
|
||||
| 'LIST_MISSING_PATCHES'
|
||||
| 'POOL_EMERGENCY_SHUTDOWN'
|
||||
| 'RBAC'
|
||||
| 'ROLLING_POOL_UPDATE'
|
||||
| 'ROLLING_POOL_REBOOT'
|
||||
| 'SMART_REBOOT'
|
||||
| 'WARM_MIGRATION'
|
||||
| 'PLUGIN.OPENMETRICS'
|
||||
|
||||
type XapiRecordByXapiXoRecord = {
|
||||
gpuGroup: XenApiGpuGroupWrapped
|
||||
host: XenApiHostWrapped
|
||||
message: XenApiMessageWrapped
|
||||
network: XenApiNetworkWrapped
|
||||
PBD: XenApiPbdWrapped
|
||||
PCI: XenApiPciWrapped
|
||||
PGPU: XenApiPgpuWrapped
|
||||
PIF: XenApiPifWrapped
|
||||
pool: XenApiPoolWrapped
|
||||
SR: XenApiSrWrapped
|
||||
SM: XenApiSmWrapped
|
||||
VBD: XenApiVbdWrapped
|
||||
VDI: XenApiVdiWrapped
|
||||
'VDI-snapshot': XenApiVdiWrapped
|
||||
'VDI-unmanaged': XenApiVdiWrapped
|
||||
vgpu: XenApiVgpuWrapped
|
||||
vgpuType: XenApiVgpuTypeWrapped
|
||||
VIF: XenApiVifWrapped
|
||||
VM: XenApiVmWrapped
|
||||
'VM-controller': XenApiVmWrapped
|
||||
'VM-snapshot': XenApiVmWrapped
|
||||
'VM-template': XenApiVmWrapped
|
||||
VTPM: XenApiVtpmWrapped
|
||||
}
|
||||
|
||||
type LicenseProductId =
|
||||
| 'premium'
|
||||
| 'xcpng-enterprise'
|
||||
| 'xcpng-standard'
|
||||
| 'xo-proxy'
|
||||
| 'xosan.trial'
|
||||
| 'xostor'
|
||||
| 'xostor.trial'
|
||||
type LicenseProductType = 'xo' | 'xoproxy' | 'xcpng' | 'xosan' | 'xostor'
|
||||
|
||||
type License = {
|
||||
id: string
|
||||
type: 'license'
|
||||
created: number
|
||||
licenseType: 'unit'
|
||||
productId: LicenseProductId
|
||||
tags: string[]
|
||||
expires?: number
|
||||
boundObjectId?: string
|
||||
bindDate?: number
|
||||
buyer?: { token: string; email: string }
|
||||
history?: { date: number; boundObjectId: string }[]
|
||||
rebound?: number
|
||||
productTypes?: LicenseProductType[]
|
||||
bundleInfo?: { name: string; id: string }
|
||||
}
|
||||
|
||||
export type XoApp = {
|
||||
hooks: EventEmitter
|
||||
_redis: {
|
||||
get(key: string): Promise<string | null>
|
||||
mSet(args: string[]): Promise<unknown>
|
||||
sMembers(key: string): Promise<string[]>
|
||||
sIsMember(key: string, member: string): Promise<boolean>
|
||||
}
|
||||
config: {
|
||||
get<T = string>(path: string): T
|
||||
getOptional<T = unknown>(path: string): T | undefined
|
||||
getOptionalDuration(path: string): number | undefined
|
||||
getGuiRoutes(): Promise<{
|
||||
default: {
|
||||
url: string
|
||||
path: string
|
||||
}
|
||||
v5?: {
|
||||
url: string
|
||||
path: string
|
||||
}
|
||||
v6?: {
|
||||
url: string
|
||||
path: string
|
||||
}
|
||||
[key: string]:
|
||||
| undefined
|
||||
| {
|
||||
url: string
|
||||
path: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
objects: EventEmitter & {
|
||||
allIndexes: {
|
||||
type: {
|
||||
getEventEmitterByType(type: XapiXoRecord['type']): EventEmitter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks: EventEmitter & {
|
||||
abort(id: XoTask['id'], reason?: string): Promise<void>
|
||||
clearLogs(): Promise<void>
|
||||
create: (params: { name: string; objectId?: string; type?: string }) => VatesTask
|
||||
deleteLog(id: XoTask['id']): Promise<void>
|
||||
get(id: XoTask['id']): Promise<XoTask>
|
||||
list(opts?: { filter?: string | ((obj: XoTask) => boolean); limit?: number }): AsyncGenerator<XoTask>
|
||||
watch(id: XoTask['id'], cb: (task: XoTask) => void): Promise<() => void>
|
||||
}
|
||||
apiContext: {
|
||||
user?: XoUser
|
||||
permission?: XoUser['permission'] | 'none' | null
|
||||
}
|
||||
|
||||
// methods ------------
|
||||
allocIpAddresses(vifId: XoVif['id'], addAddresses?: string[], removeAddresses?: string[]): Promise<void>
|
||||
addAclV2GroupRole(groupId: XoGroup['id'], roleId: XoAclRole['id']): Promise<XoGroupRole>
|
||||
addAclV2UserRole(userId: XoUser['id'], roleId: XoAclRole['id']): Promise<XoUserRole>
|
||||
addUserToGroup: (userId: XoUser['id'], groupId: XoGroup['id']) => Promise<void>
|
||||
addApiMethod: <A extends unknown[], R>(
|
||||
name: string,
|
||||
method: (...args: A) => Promise<R>,
|
||||
//we use any since it is a legacy call, might be better typed in another pr
|
||||
info: {
|
||||
resolve?: any
|
||||
params?: any
|
||||
}
|
||||
) => () => void // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
registerRestRoutes: (routes: PluginRestRouteDefinition[], base?: string) => () => void
|
||||
authenticateUser: (
|
||||
credentials: { token?: string; username?: string; password?: string },
|
||||
userData?: { ip?: string },
|
||||
opts?: { bypassOtp?: boolean; bypassTaskCreation?: boolean }
|
||||
) => Promise<{ bypassOtp: boolean; expiration: number; user: XoUser }>
|
||||
backupGuard(poolId: XoPool['id']): Promise<void>
|
||||
/* Throw if no authorization */
|
||||
checkFeatureAuthorization(featureCode: FeatureCode): Promise<void>
|
||||
/* connect a server (XCP-ng/XenServer) */
|
||||
connectXenServer(id: XoServer['id']): Promise<void>
|
||||
// TODO: replace all XoAclBasePrivilege with a more strict type. (discriminate union)
|
||||
createAclV2Privilege(
|
||||
privilege: Omit<XoAclBasePrivilege, 'id'>,
|
||||
options?: {
|
||||
force?: boolean
|
||||
}
|
||||
): Promise<XoAclBasePrivilege>
|
||||
copyAclV2Role(
|
||||
id: XoAclRole['id'],
|
||||
params?: { name?: XoAclRole['name']; description?: XoAclRole['description'] }
|
||||
): Promise<XoAclRole['id']>
|
||||
createAclV2Role(role: { name: XoAclRole['name']; description?: XoAclRole['description'] }): Promise<XoAclRole>
|
||||
createAuthenticationToken(opts: {
|
||||
client?: {
|
||||
id?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
description?: string
|
||||
expiresIn?: string | number
|
||||
userId: XoUser['id']
|
||||
}): Promise<XoAuthenticationToken>
|
||||
createRemote(params: {
|
||||
name: string
|
||||
options?: string
|
||||
proxy?: XoProxy['id']
|
||||
url: string
|
||||
}): Promise<XoBackupRepository>
|
||||
createUser(params: { name?: string; password?: string; [key: string]: unknown }): Promise<XoUser>
|
||||
deleteAclV2GroupRole(
|
||||
groupId: XoGroup['id'],
|
||||
roleId: XoAclRole['id'],
|
||||
opts?: { bypassAuthorization?: boolean }
|
||||
): Promise<boolean>
|
||||
deleteAclV2Privilege(privilegeId: XoAclBasePrivilege['id'], options?: { force?: boolean }): Promise<boolean>
|
||||
deleteAclV2Role(roleId: XoAclRole['id'], options?: { force?: boolean }): Promise<boolean>
|
||||
deleteGroup(id: XoGroup['id']): Promise<void>
|
||||
deleteUser(id: XoUser['id']): Promise<void>
|
||||
detachHostFromPool(hostId: XoHost['id']): Promise<void>
|
||||
createGroup(params: { name: string; provider?: string; providerGroup?: string }): Promise<XoGroup>
|
||||
/* disconnect a server (XCP-ng/XenServer) */
|
||||
disconnectXenServer(id: XoServer['id']): Promise<void>
|
||||
findEnabledScheduleSequenceFromSchedule(id: XoSchedule['id']): Promise<XoSchedule | undefined>
|
||||
getAclV2Privilege(id: XoAclBasePrivilege['id']): Promise<XoAclBasePrivilege>
|
||||
getAclV2Privileges(): Promise<XoAclBasePrivilege[]>
|
||||
getAclV2RolePrivileges(roleId: XoAclRole['id']): Promise<XoAclBasePrivilege[]>
|
||||
getAclV2Role(id: XoAclRole['id'], opts?: { bypassAuthorization?: boolean }): Promise<XoAclRole>
|
||||
deleteAclV2UserRole(
|
||||
userId: XoUser['id'],
|
||||
roleId: XoAclRole['id'],
|
||||
opts?: { bypassAuthorization?: boolean }
|
||||
): Promise<boolean>
|
||||
getAclV2GroupRoles(
|
||||
groupId: XoGroup['id'],
|
||||
opts?: { bypassAuthorization?: boolean }
|
||||
): Promise<Exclude<XoAclRole, { isTemplate: true }>[]>
|
||||
getAclV2Roles(): Promise<XoAclRole[]>
|
||||
getAclV2UserPrivileges(userId: XoUser['id']): Promise<XoAclBasePrivilege[]>
|
||||
getAclV2UserRoles(
|
||||
userId: XoUser['id'],
|
||||
opts?: { bypassAuthorization?: boolean; fromGroup?: boolean; fromUser?: boolean }
|
||||
): Promise<XoAclRole[]>
|
||||
getAllGroups(): Promise<XoGroup[]>
|
||||
getAllProxies(): Promise<XoProxy[]>
|
||||
getAllJobs<T extends AnyXoJob['type']>(type: T): Promise<Extract<AnyXoJob, { type: T }>[]>
|
||||
getAllJobs(type?: string): Promise<AnyXoJob[]>
|
||||
getProxy(id: XoProxy['id']): Promise<XoProxy>
|
||||
getRemote(id: XoBackupRepository['id']): Promise<XoBackupRepository>
|
||||
getAllRemotes(): Promise<XoBackupRepository[]>
|
||||
removeRemote(id: XoBackupRepository['id']): Promise<void>
|
||||
getAllRemotesInfo(): Promise<
|
||||
Record<
|
||||
XoBackupRepository['id'],
|
||||
{
|
||||
size?: number
|
||||
used?: number
|
||||
available?: number
|
||||
encryption?: {
|
||||
algorithm: string
|
||||
isLegacy: boolean
|
||||
recommanded: string
|
||||
}
|
||||
}
|
||||
>
|
||||
>
|
||||
getAllSchedules(): Promise<XoSchedule[]>
|
||||
getAllUsers(): Promise<XoUser[]>
|
||||
getAllXenServers(): Promise<XoServer[]>
|
||||
getAuthenticationTokensForUser(userId: XoUser['id']): Promise<XoAuthenticationToken[]>
|
||||
getBackupNgLogs(): Promise<Record<string, AnyXoLog>>
|
||||
getBackupNgLogs(id: AnyXoLog['id']): Promise<AnyXoLog>
|
||||
getBackupNgLogsSorted(opts: {
|
||||
after?: number
|
||||
before?: number
|
||||
filter: (log: AnyXoLog) => boolean
|
||||
limit?: number
|
||||
}): Promise<AnyXoLog[]>
|
||||
getGroup(id: XoGroup['id']): Promise<XoGroup>
|
||||
getHVSupportedVersions: undefined | (() => Promise<{ [key: XoHost['productBrand']]: string }>)
|
||||
getJob<T extends AnyXoJob>(id: T['id']): Promise<T>
|
||||
getObject: <T extends XapiXoRecord>(id: T['id'], type?: T['type'] | T['type'][]) => T
|
||||
getObjectsByType: <T extends XapiXoRecord>(
|
||||
type: T['type'],
|
||||
opts?: { filter?: string | ((obj: T) => boolean); limit?: number }
|
||||
) => Record<T['id'], T> | undefined
|
||||
getTotalBackupSizeOnRemote(id: XoBackupRepository['id']): Promise<{ onDisk: number }>
|
||||
getSchedule(id: XoSchedule['id']): Promise<XoSchedule>
|
||||
getUser: (id: XoUser['id']) => Promise<XoUser>
|
||||
getXapi(maybeId: XapiXoRecord['id'] | XapiXoRecord): Xapi
|
||||
getXapiHostStats: (hostId: XoHost['id'], granularity?: XapiStatsGranularity) => Promise<XapiHostStats>
|
||||
getXapiObject: <T extends XapiXoRecord>(
|
||||
maybeId: T['id'] | T,
|
||||
type: T['type'] | T['type'][]
|
||||
) => XapiRecordByXapiXoRecord[T['type']]
|
||||
getXapiPoolStats(poolId: XoPool['id'], granularity?: XapiStatsGranularity): Promise<XapiPoolStats>
|
||||
getXapiVmStats: (vmId: XoVm['id'], granularity?: XapiStatsGranularity) => Promise<XapiVmStats>
|
||||
getXenServer(id: XoServer['id']): Promise<XoServer>
|
||||
hasFeatureAuthorization(featureCode: string): Promise<boolean>
|
||||
hasObject<T extends XapiXoRecord>(id: T['id'], type: T['type']): boolean
|
||||
listMetadataBackups(backupRepositoryIds: XoBackupRepository['id'][]): Promise<{
|
||||
xo: Record<XoBackupRepository['id'], XoConfigBackupArchive[]>
|
||||
pool: Record<XoBackupRepository['id'], Record<XoPool['id'], XoPoolBackupArchive[]>>
|
||||
}>
|
||||
listVmBackupsNg(
|
||||
backupRepositoryIds: XoBackupRepository['id'][],
|
||||
opts?: { _forceRefresh?: boolean; vmId: XoVm['id'] }
|
||||
): Promise<Record<XoBackupRepository['id'], Record<XoVm['id'], XoVmBackupArchive[]>>>
|
||||
pingRemote(id: XoBackupRepository['id']): Promise<{ success: true }>
|
||||
/** Allow to add a new server in the DB (XCP-ng/XenServer) */
|
||||
registerXenServer(
|
||||
body: Pick<XoServer, 'host' | 'httpProxy' | 'label' | 'username'> & {
|
||||
allowUnauthorized?: XoServer['allowUnauthorized']
|
||||
password: string
|
||||
readOnly?: XoServer['readOnly']
|
||||
}
|
||||
): Promise<XoServer>
|
||||
rollingPoolReboot(pool: XoPool, opts?: { parentTask?: VatesTask; shutdownPinnedVms?: boolean }): Promise<void>
|
||||
rollingPoolUpdate(
|
||||
pool: XoPool,
|
||||
opts?: { rebootVm?: boolean; parentTask?: VatesTask; shutdownPinnedVms?: boolean }
|
||||
): Promise<void>
|
||||
setVmResourceSet(vmId: XoVm['id'], resourceSetId: string | null, force?: boolean): Promise<void>
|
||||
shareVmResourceSet(vmId: XoVm['id']): Promise<void>
|
||||
removeUserFromGroup(userId: XoUser['id'], id: XoGroup['id']): Promise<void>
|
||||
runJob(job: AnyXoJob, schedule: XoSchedule): void
|
||||
runWithApiContext: (user: XoUser | undefined, fn: () => void) => Promise<unknown>
|
||||
testRemote(
|
||||
id: XoBackupRepository['id']
|
||||
): Promise<
|
||||
| { success: true; readRate: number; writeRate: number }
|
||||
| { success: false; step: string; file: string; error: unknown }
|
||||
>
|
||||
/** Remove a server from the DB (XCP-ng/XenServer) */
|
||||
unregisterXenServer(id: XoServer['id']): Promise<void>
|
||||
updateUser(
|
||||
id: XoUser['id'],
|
||||
updates: {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
email?: string
|
||||
authProviders?: Record<string, string>
|
||||
name?: string
|
||||
password?: string
|
||||
permission?: string
|
||||
preferences?: Record<string, string>
|
||||
}
|
||||
): Promise<void>
|
||||
updateAclV2Privilege(
|
||||
privilegeId: XoAclBasePrivilege['id'],
|
||||
privilege: XoAclBasePrivilege
|
||||
): Promise<XoAclBasePrivilege>
|
||||
updateAclV2Role(
|
||||
roleId: XoAclRole['id'],
|
||||
role: {
|
||||
name?: XoAclRole['name']
|
||||
description?: XoAclRole['description'] | null
|
||||
},
|
||||
options?: {
|
||||
force?: boolean
|
||||
}
|
||||
): Promise<XoAclRole>
|
||||
updateGroup(
|
||||
id: XoGroup['id'],
|
||||
updates: {
|
||||
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>
|
||||
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>
|
||||
unbindLicense(params: {
|
||||
productId: LicenseProductId
|
||||
boundObjectId: string
|
||||
licenseId: string
|
||||
data?: Record<string, string>
|
||||
}): Promise<License>
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Types based on xapi-object-to-xo
|
||||
|
||||
import type {
|
||||
BACKUP_TYPE,
|
||||
Branded,
|
||||
DOMAIN_TYPE,
|
||||
HOST_ALLOWED_OPERATIONS,
|
||||
@@ -18,9 +19,6 @@ import type {
|
||||
VM_OPERATIONS,
|
||||
VM_POWER_STATE,
|
||||
} from './common.mjs'
|
||||
import type * as CMType from './lib/complex-matcher.mjs'
|
||||
import { XoAclPrivilege, XoAclRole, XoAclSupportedActionsByResource } from './lib/xen-orchestra-acl.mjs'
|
||||
import type { XenApiHost, XenApiPool } from './xen-api.mjs'
|
||||
|
||||
type BaseXapiXo = {
|
||||
$pool: XoPool['id']
|
||||
@@ -76,7 +74,7 @@ type BaseXoVm = BaseXapiXo & {
|
||||
expNestedHvm: boolean
|
||||
isNestedVirtEnabled: boolean
|
||||
hasVendorDevice: boolean
|
||||
high_availability: 'best-effort' | 'restart' | ''
|
||||
high_availability: string
|
||||
installTime?: number | null
|
||||
isFirmwareSupported: boolean
|
||||
memory: {
|
||||
@@ -94,7 +92,7 @@ type BaseXoVm = BaseXapiXo & {
|
||||
notes?: string
|
||||
os_version: Record<string, string> | null
|
||||
other: Record<string, string>
|
||||
parent?: XoVmSnapshot['id']
|
||||
parent?: XoVm['id']
|
||||
power_state: VM_POWER_STATE
|
||||
pvDriversDetected?: boolean
|
||||
pvDriversUpToDate?: boolean
|
||||
@@ -107,10 +105,6 @@ type BaseXoVm = BaseXapiXo & {
|
||||
suspendSr?: XoSr['id']
|
||||
tags: string[]
|
||||
vga?: string
|
||||
/**
|
||||
* Value in MiB.
|
||||
* See: https://wiki.xenproject.org/wiki/XCP_PV_templates_start
|
||||
*/
|
||||
videoram?: number
|
||||
viridian: boolean
|
||||
virtualizationMode: DOMAIN_TYPE
|
||||
@@ -139,67 +133,6 @@ export type XoAlarm = Omit<XoMessage, '$object' | 'body'> & {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: to be typed when Bastien.N has finished working on the XO task
|
||||
type BaseXoLog = {
|
||||
id: Branded<'xo-log'>
|
||||
infos?: { data: unknown; message: string }[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type XoBackupLog = BaseXoLog & {
|
||||
status: 'success' | 'skipped' | 'interrupted' | 'failure' | 'pending'
|
||||
message: 'backup'
|
||||
start: number
|
||||
end?: number
|
||||
tasks?: XoTask[]
|
||||
jobId?: AnyXoBackupJob['id']
|
||||
}
|
||||
export type XoRestoreLog = BaseXoLog & {
|
||||
message: 'restore'
|
||||
}
|
||||
export type XoVmBackupArchive = {
|
||||
id: Branded<'vm-backup-archive'>
|
||||
type: 'xo-vm-backup'
|
||||
backupRepository: XoBackupRepository['id']
|
||||
disks: { id: string; name: string; uuid: XoVdiSnapshot['uuid'] }[]
|
||||
isImmutable?: boolean
|
||||
jobId: XoVmBackupJob['id']
|
||||
mode: XoVmBackupJob['mode']
|
||||
scheduleId: XoSchedule['id']
|
||||
size: number
|
||||
timestamp: number
|
||||
vm: {
|
||||
uuid: XoVm['uuid']
|
||||
name_description: string
|
||||
name_label: string
|
||||
tags: XoVm['tags']
|
||||
}
|
||||
differencingVhds?: number
|
||||
dynamicVhds?: number
|
||||
withMemory: boolean
|
||||
}
|
||||
|
||||
type XoMetadataBackupArchive = {
|
||||
id: Branded<'metadata-backup-archive'>
|
||||
backupRepository: XoBackupRepository['id']
|
||||
jobId: XoVmBackupJob['id']
|
||||
jobName: XoVmBackupJob['name']
|
||||
scheduleId: XoSchedule['id']
|
||||
scheduleName: XoSchedule['name']
|
||||
timestamp: number
|
||||
}
|
||||
export type XoConfigBackupArchive = XoMetadataBackupArchive & {
|
||||
id: Branded<'config-backup-archive'>
|
||||
data: string
|
||||
type: 'xo-config-backup'
|
||||
}
|
||||
|
||||
export type XoPoolBackupArchive = XoMetadataBackupArchive & {
|
||||
id: Branded<'pool-backup-archive'>
|
||||
pool: XenApiPool
|
||||
poolMaster: XenApiHost
|
||||
type: 'xo-pool-metadata-backup'
|
||||
}
|
||||
|
||||
export type XoBackupRepository = {
|
||||
benchmarks?: { readRate: number; timestamp: number; writeRate: number }[]
|
||||
enabled: boolean
|
||||
@@ -222,7 +155,6 @@ export type XoGroup = {
|
||||
provider?: string
|
||||
providerGroupId?: string
|
||||
users: XoUser['id'][]
|
||||
aclRoleIds: XoAclRole['id'][]
|
||||
}
|
||||
|
||||
export type XoHost = BaseXapiXo & {
|
||||
@@ -241,7 +173,7 @@ export type XoHost = BaseXapiXo & {
|
||||
|
||||
address: string
|
||||
agentStartTime: null | number
|
||||
bios_strings: Record<string, string>
|
||||
bios_string: Record<string, string>
|
||||
build: string
|
||||
certificates?: {
|
||||
fingerprint: string
|
||||
@@ -250,7 +182,7 @@ export type XoHost = BaseXapiXo & {
|
||||
chipset_info: {
|
||||
iommu?: boolean
|
||||
}
|
||||
controlDomain?: XoVmController['id']
|
||||
controlDomain?: XoVm['id']
|
||||
cpus: {
|
||||
cores?: number
|
||||
sockets?: number
|
||||
@@ -336,7 +268,6 @@ export type XoNetwork = BaseXapiXo & {
|
||||
defaultIsLocked: boolean
|
||||
id: Branded<'network'>
|
||||
insecureNbd?: boolean
|
||||
isBonded: boolean
|
||||
name_description: string
|
||||
name_label: string
|
||||
nbd?: boolean
|
||||
@@ -346,19 +277,7 @@ export type XoNetwork = BaseXapiXo & {
|
||||
}
|
||||
|
||||
export type XoPbd = BaseXapiXo & {
|
||||
attached: boolean
|
||||
device_config:
|
||||
| { device: string }
|
||||
| { location: string }
|
||||
| { path: string; location: string; legacy_mode: string }
|
||||
| { provisioning: string; redundancy: string; 'group-name': string }
|
||||
| { server: string; serverpath: string }
|
||||
| { type: string; location: string }
|
||||
| Record<string, string>
|
||||
host: XoHost['id']
|
||||
id: Branded<'PBD'>
|
||||
otherConfig: Record<string, string>
|
||||
SR: XoSr['id']
|
||||
type: 'PBD'
|
||||
}
|
||||
|
||||
@@ -399,7 +318,7 @@ export type XoPif = BaseXapiXo & {
|
||||
|
||||
attached: boolean
|
||||
bondMaster?: XoPif['id']
|
||||
bondSlaves?: XoPif['id'][]
|
||||
bondSalves?: XoPif['id'][]
|
||||
carrier: boolean
|
||||
device: string
|
||||
deviceName?: string
|
||||
@@ -432,9 +351,8 @@ export type XoPool = BaseXapiXo & {
|
||||
}
|
||||
crashDumpSr?: XoSr['id']
|
||||
current_operations: Record<string, POOL_ALLOWED_OPERATIONS>
|
||||
default_SR?: XoSr['id']
|
||||
defaultSr?: XoSr['id']
|
||||
HA_enabled: boolean
|
||||
haRebootVmOnInternalShutdown: boolean
|
||||
haSrs: XoSr['id'][]
|
||||
id: Branded<'pool'>
|
||||
master: XoHost['id']
|
||||
@@ -447,144 +365,69 @@ export type XoPool = BaseXapiXo & {
|
||||
tags: string[]
|
||||
type: 'pool'
|
||||
vtpmSupported: boolean
|
||||
xosanPackInstallationTime: number | null
|
||||
zstdSupported: boolean
|
||||
}
|
||||
|
||||
export type XoProxy = {
|
||||
id: Branded<'proxy'>
|
||||
url: string
|
||||
version?: string
|
||||
name: string
|
||||
} & (
|
||||
| { address?: undefined; vmUuid: XoVm['id'] }
|
||||
| { address: string; vmUuid?: undefined }
|
||||
| { address: string; vmUuid: XoVm['id'] }
|
||||
)
|
||||
}
|
||||
|
||||
type BaseXoJob = {
|
||||
id: Branded<'job'>
|
||||
name?: string
|
||||
}
|
||||
|
||||
type XoBackupJobGeneralSettings = {
|
||||
backupReportTpl?: 'compactMjml' | 'mjml'
|
||||
reportWhen?: 'always' | 'error' | 'failure' | 'never'
|
||||
reportRecipients?: string[]
|
||||
hideSuccessfulItems?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type XoVmBackupJobGeneralSettings = XoBackupJobGeneralSettings & {
|
||||
cbtDestroySnapshotData?: boolean
|
||||
concurrency?: number
|
||||
longTermRetention?: {
|
||||
daily?: { retention: number; settings: Record<string, unknown> }
|
||||
weekly?: { retention: number; settings: Record<string, unknown> }
|
||||
monthly?: { retention: number; settings: Record<string, unknown> }
|
||||
yearly?: { retention: number; settings: Record<string, unknown> }
|
||||
}
|
||||
checkpointSnapshot?: boolean
|
||||
offlineSnapshot?: boolean
|
||||
maxExportRate?: number
|
||||
nbdConcurrency?: number
|
||||
nRetriesVmBackupFailures?: number
|
||||
preferNbd?: boolean
|
||||
timezone?: string
|
||||
mergeBackupsSynchronously?: boolean
|
||||
offlineBackup?: boolean
|
||||
timeout?: number
|
||||
}
|
||||
export type XoVmBackupJobScheduleSettings = {
|
||||
exportRetention?: number
|
||||
healthCheckVmsWithTags?: XoVm['tags']
|
||||
fullInterval?: number
|
||||
copyRetention?: number
|
||||
snapshotRetention?: number
|
||||
cbtDestroySnapshotData?: boolean
|
||||
healthCheckSr?: XoSr['id']
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type XoVmBackupJob = BaseXoJob & {
|
||||
// @TODO: create type for complex matcher
|
||||
export type XoBackupJob = BaseXoJob & {
|
||||
compression?: 'native' | 'zstd' | ''
|
||||
proxy?: XoProxy['id']
|
||||
mode: 'full' | 'delta'
|
||||
remotes?: CMType.IdOr<XoBackupRepository['id']>
|
||||
vms: CMType.IdOr<XoVm['id']> | Record<string, unknown>
|
||||
srs?: CMType.IdOr<XoSr['id']>
|
||||
type: 'backup'
|
||||
settings: {
|
||||
'': XoVmBackupJobGeneralSettings
|
||||
[key: XoSchedule['id']]: XoVmBackupJobScheduleSettings | undefined
|
||||
name?: string
|
||||
remotes?: {
|
||||
id: XoBackupRepository['id'] | { __or: XoBackupRepository['id'][] }
|
||||
}
|
||||
}
|
||||
|
||||
export type XoMetadataBackupJobGeneralSettings = XoBackupJobGeneralSettings
|
||||
export type XoMetadataBackupJobScheduleSettings = {
|
||||
retentionPoolMetadata?: number
|
||||
retentionXoMetadata?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type XoMetadataBackupJob = BaseXoJob & {
|
||||
type: 'metadataBackup'
|
||||
pools?: CMType.IdOr<XoPool['id']>
|
||||
remotes: CMType.IdOr<XoBackupRepository['id']>
|
||||
settings: {
|
||||
''?: XoMetadataBackupJobGeneralSettings
|
||||
[scheduleId: XoSchedule['id']]: XoMetadataBackupJobScheduleSettings | undefined
|
||||
vms?: {
|
||||
id: XoVm['id'] | { __or: XoVm['id'][] } | Record<string, unknown>
|
||||
}
|
||||
xoMetadata?: boolean
|
||||
userId: XoUser['id']
|
||||
proxy?: XoProxy['id']
|
||||
}
|
||||
|
||||
export type XoMirrorBackupGeneralSettings = XoBackupJobGeneralSettings & {
|
||||
concurrency?: number
|
||||
nRetriesVmBackupFailures?: number
|
||||
mergeBackupsSynchronously?: boolean
|
||||
timeout?: number
|
||||
maxExportRate?: number
|
||||
backupReportTpl?: 'compactMjml'
|
||||
reportWhen: 'failure'
|
||||
}
|
||||
export type XoMirrorBackupScheduleSettings = {
|
||||
exportRetention?: number
|
||||
healthCheckVmsWithTags?: XoVm['tags']
|
||||
healthCheckSr?: XoSr['id']
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type XoMirrorBackupJob = BaseXoJob & {
|
||||
type: 'mirrorBackup'
|
||||
mode: 'full' | 'delta'
|
||||
sourceRemote: XoBackupRepository['id']
|
||||
remotes: CMType.IdOr<XoBackupRepository['id']>
|
||||
proxy?: XoProxy['id']
|
||||
settings: {
|
||||
'': XoMirrorBackupGeneralSettings
|
||||
[scheduleId: XoSchedule['id']]: XoMirrorBackupScheduleSettings | undefined
|
||||
srs: {
|
||||
id: XoSr['id'] | { __or: XoSr['id'][] }
|
||||
}
|
||||
}
|
||||
|
||||
export type XoJob = BaseXoJob & {
|
||||
type: 'call'
|
||||
method: string
|
||||
paramsVector?: {
|
||||
type: string
|
||||
items: {
|
||||
type?: string
|
||||
values?: {
|
||||
schedules?: XoSchedule['id'][]
|
||||
[key: string]: unknown
|
||||
}[]
|
||||
type: BACKUP_TYPE
|
||||
settings: {
|
||||
'': {
|
||||
cbtDestroySnapshotData?: boolean
|
||||
concurrency?: number
|
||||
longTermRetention?: {
|
||||
daily?: { retention: number; settings: Record<string, unknown> }
|
||||
weekly?: { retention: number; settings: Record<string, unknown> }
|
||||
monthly?: { retention: number; settings: Record<string, unknown> }
|
||||
yearly?: { retention: number; settings: Record<string, unknown> }
|
||||
}
|
||||
maxExportRate?: number
|
||||
nbdConcurrency?: number
|
||||
nRetriesVmBackupFailures?: number
|
||||
preferNbd?: boolean
|
||||
timezone?: string
|
||||
[key: string]: unknown
|
||||
}[]
|
||||
}
|
||||
[key: XoSchedule['id']]: {
|
||||
exportRetention?: number
|
||||
healthCheckSr?: XoSr['id']
|
||||
healthCheckVmsWithTags?: string[]
|
||||
fullInterval?: number
|
||||
copyRetention?: number
|
||||
snapshotRetention?: number
|
||||
cbtDestroySnapshotData?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
export type XoJob = BaseXoJob & {}
|
||||
|
||||
export type XoSchedule = {
|
||||
cron: string
|
||||
enabled: boolean
|
||||
id: Branded<'schedule'>
|
||||
jobId: AnyXoJob['id']
|
||||
jobId: (XoJob | XoBackupJob)['id']
|
||||
name?: string
|
||||
timezone?: string
|
||||
}
|
||||
@@ -613,8 +456,8 @@ export type XoSr = BaseXapiXo & {
|
||||
|
||||
VDIs: AnyXoVdi['id'][]
|
||||
|
||||
allocationStrategy?: 'thin' | 'thick' | 'unknown'
|
||||
content_type: 'user' | 'iso' | 'disk'
|
||||
allocationStrategy: 'thin' | 'thick' | 'unknown'
|
||||
content_type: string
|
||||
current_operations: Record<string, STORAGE_OPERATIONS>
|
||||
id: Branded<'SR'>
|
||||
inMaintenanceMode: boolean
|
||||
@@ -625,28 +468,7 @@ export type XoSr = BaseXapiXo & {
|
||||
shared: boolean
|
||||
size: number
|
||||
sm_config: Record<string, string>
|
||||
// SM drivers shipped with XCP-ng/XenServer
|
||||
SR_type:
|
||||
| 'cephfs'
|
||||
| 'dummy'
|
||||
| 'ext'
|
||||
| 'file'
|
||||
| 'glusterfs'
|
||||
| 'hba'
|
||||
| 'iso'
|
||||
| 'largeblock'
|
||||
| 'linstor'
|
||||
| 'lvm'
|
||||
| 'lvmofcoe'
|
||||
| 'lvmohba'
|
||||
| 'lvmoiscsi'
|
||||
| 'moosefs'
|
||||
| 'nfs'
|
||||
| 'shm'
|
||||
| 'smb'
|
||||
| 'udev'
|
||||
| 'xfs'
|
||||
| 'zfs'
|
||||
SR_type: string
|
||||
tags: string[]
|
||||
type: 'SR'
|
||||
usage: number
|
||||
@@ -667,72 +489,17 @@ export type XoSm = BaseXapiXo & {
|
||||
type: 'SM'
|
||||
}
|
||||
|
||||
export type XoTask = {
|
||||
abortionRequestedAt?: number
|
||||
data?: Record<string, string>
|
||||
end?: number
|
||||
id: Branded<'task'>
|
||||
infos?: { data: unknown; message: string }[]
|
||||
progress?: number
|
||||
properties: {
|
||||
method?: string
|
||||
name?: string
|
||||
objectId?: string
|
||||
objectType?:
|
||||
| XapiXoRecord['type']
|
||||
| 'backup'
|
||||
| 'backup-archive'
|
||||
| 'backup-job'
|
||||
| 'backup-log'
|
||||
| 'backup-repository'
|
||||
| 'group'
|
||||
| 'proxy'
|
||||
| 'restore'
|
||||
| 'restore-log'
|
||||
| 'schedule'
|
||||
| 'server'
|
||||
| 'task'
|
||||
| 'user'
|
||||
| 'acl-privilege'
|
||||
| 'acl-role'
|
||||
params?: Record<string, unknown>
|
||||
progress?: number
|
||||
type?: string
|
||||
userId?: string
|
||||
[key: string]: unknown | undefined
|
||||
}
|
||||
result?: Record<string, unknown>
|
||||
start: number
|
||||
status: 'failure' | 'interrupted' | 'pending' | 'success'
|
||||
tasks?: XoTask[]
|
||||
updatedAt?: number
|
||||
warnings?: { data: unknown; message: string }[]
|
||||
}
|
||||
|
||||
export type XoUser = {
|
||||
authProviders?: Record<string, string>
|
||||
email: string
|
||||
groups: XoGroup['id'][]
|
||||
id: Branded<'user'>
|
||||
name?: string
|
||||
permission: 'none' | 'admin'
|
||||
permission: string
|
||||
pw_hash?: string
|
||||
preferences: Record<string, string>
|
||||
}
|
||||
|
||||
export type XoAuthenticationToken = {
|
||||
client?: {
|
||||
id: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
created_at?: number
|
||||
description?: string
|
||||
user_id: XoUser['id']
|
||||
expiration: number
|
||||
last_uses?: Record<string, { timestamp: number }>
|
||||
id: Branded<'authentication-token'>
|
||||
}
|
||||
|
||||
export type XoVbd = BaseXapiXo & {
|
||||
attached: boolean
|
||||
bootable: boolean
|
||||
@@ -768,7 +535,6 @@ type BaseXoVdi = BaseXapiXo & {
|
||||
|
||||
export type XoVdi = BaseXoVdi & {
|
||||
id: Branded<'VDI'>
|
||||
chainPhysicalUsage: number
|
||||
type: 'VDI'
|
||||
}
|
||||
|
||||
@@ -869,7 +635,6 @@ export type XapiXoRecord =
|
||||
| XoHost
|
||||
| XoMessage
|
||||
| XoNetwork
|
||||
| XoPbd
|
||||
| XoPci
|
||||
| XoPgpu
|
||||
| XoPif
|
||||
@@ -889,37 +654,12 @@ export type XapiXoRecord =
|
||||
| XoVtpm
|
||||
| XoSm
|
||||
|
||||
export type NonXapiXoRecord<
|
||||
TActionsByResource extends XoAclSupportedActionsByResource = never,
|
||||
TResource extends string = never,
|
||||
> =
|
||||
| AnyXoBackupArchive
|
||||
| AnyXoJob
|
||||
| AnyXoLog
|
||||
| XoGroup
|
||||
| XoProxy
|
||||
| XoJob
|
||||
| XoBackupRepository
|
||||
| XoSchedule
|
||||
| XoServer
|
||||
| XoTask
|
||||
| XoUser
|
||||
| XoAclRole
|
||||
| XoAclPrivilege<TActionsByResource, TResource>
|
||||
export type NonXapiXoRecord = XoGroup | XoProxy | XoJob | XoBackupRepository | XoSchedule | XoServer | XoUser
|
||||
|
||||
export type XoRecord<
|
||||
TActionsByResource extends XoAclSupportedActionsByResource = never,
|
||||
TResource extends string = never,
|
||||
> = XapiXoRecord | NonXapiXoRecord<TActionsByResource, TResource>
|
||||
export type XoRecord = XapiXoRecord | NonXapiXoRecord
|
||||
|
||||
export type AnyXoVm = XoVm | XoVmSnapshot | XoVmTemplate | XoVmController
|
||||
|
||||
export type AnyXoVdi = XoVdi | XoVdiSnapshot | XoVdiUnmanaged
|
||||
|
||||
export type AnyXoJob = XoJob | AnyXoBackupJob
|
||||
|
||||
export type AnyXoBackupJob = XoVmBackupJob | XoMetadataBackupJob | XoMirrorBackupJob
|
||||
|
||||
export type AnyXoLog = XoBackupLog | XoRestoreLog
|
||||
|
||||
export type AnyXoBackupArchive = XoVmBackupArchive | XoConfigBackupArchive | XoPoolBackupArchive
|
||||
export type AnyXoJob = XoJob | XoBackupJob
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
"strict": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES2022",
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"] // Only use types from Node.js and packages that are explicitly imported
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../scripts/npmignore
|
||||
@@ -1,16 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended, {
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"main": "dist/index.mjs",
|
||||
"name": "@xen-orchestra/acl",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/acl",
|
||||
"version": "1.5.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">= 20.18"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && node scripts/generate-types.mjs",
|
||||
"clean": "rimraf dist/",
|
||||
"dev": "tsc --watch",
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "npm run build && node --test ./dist/**/*.test.mjs",
|
||||
"prebuild": "npm run clean",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@vates/types": "^1.30.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "~5.6",
|
||||
"typescript-eslint": "^8.61.0"
|
||||
},
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@xen-orchestra/acl",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"complex-matcher": "^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// AUTO-GENERATES dist/generated/privilege-types.d.mts
|
||||
// Run via `npm run build` (after tsc).
|
||||
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { SUPPORTED_ACTIONS_BY_RESOURCE } from '../dist/actions/index.mjs'
|
||||
import { getActionStrings } from '../dist/index.mjs'
|
||||
|
||||
function toPascalCase(str) {
|
||||
return str
|
||||
.split(/[-_]/)
|
||||
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join('')
|
||||
}
|
||||
|
||||
const resources = Object.keys(SUPPORTED_ACTIONS_BY_RESOURCE)
|
||||
|
||||
const typeBlocks = resources.map(resource => {
|
||||
const actionUnion = getActionStrings(resource)
|
||||
.map(action => `'${action}'`)
|
||||
.join(' | ')
|
||||
return `export type ${toPascalCase(resource)}Privilege = {
|
||||
id: XoAclBasePrivilege['id']
|
||||
resource: '${resource}'
|
||||
action: ${actionUnion}
|
||||
selector?: XoAclBasePrivilege['selector']
|
||||
effect: XoAclBasePrivilege['effect']
|
||||
roleId: XoAclBasePrivilege['roleId']
|
||||
}`
|
||||
})
|
||||
|
||||
const anyUnion = resources.map(resource => ` | ${toPascalCase(resource)}Privilege`).join('\n')
|
||||
|
||||
const types = `\
|
||||
// -----------------------------------------------------------------------
|
||||
// AUTO-GENERATED — do not edit manually.
|
||||
// Regenerated on every \`npm run build\`.
|
||||
// -----------------------------------------------------------------------
|
||||
import type { XoAclBasePrivilege } from '@vates/types'
|
||||
|
||||
|
||||
${typeBlocks.join('\n\n')}
|
||||
|
||||
export type AnyPrivilege =
|
||||
${anyUnion}
|
||||
`
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const outDir = join(root, 'dist/generated')
|
||||
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
writeFileSync(join(outDir, 'privilege-types.d.mts'), types, 'utf-8')
|
||||
@@ -1,11 +0,0 @@
|
||||
export default {
|
||||
create: true,
|
||||
delete: true,
|
||||
read: true,
|
||||
update: {
|
||||
action: true,
|
||||
effect: true,
|
||||
resource: true,
|
||||
selector: true,
|
||||
},
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export default {
|
||||
create: true,
|
||||
delete: true,
|
||||
read: true,
|
||||
update: {
|
||||
description: true,
|
||||
groups: true,
|
||||
name: true,
|
||||
users: true,
|
||||
},
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export default {
|
||||
benchmark: true,
|
||||
create: true,
|
||||
read: true,
|
||||
forget: true,
|
||||
update: {
|
||||
enabled: true,
|
||||
name: true,
|
||||
options: true,
|
||||
proxy: true,
|
||||
url: true,
|
||||
},
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export default {
|
||||
create: true,
|
||||
delete: true,
|
||||
read: true,
|
||||
update: {
|
||||
name: true,
|
||||
users: true,
|
||||
},
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export default {
|
||||
'allow-vm': true,
|
||||
detach: true,
|
||||
disable: true,
|
||||
enable: true,
|
||||
evacuate: true,
|
||||
export: {
|
||||
logs: true,
|
||||
},
|
||||
forget: true,
|
||||
'join-pool': true,
|
||||
'migrate-receive': true,
|
||||
read: true,
|
||||
reboot: {
|
||||
clean: true,
|
||||
smart: true,
|
||||
},
|
||||
'restart-toolstack': true,
|
||||
'scan-pifs': true,
|
||||
shutdown: {
|
||||
clean: true,
|
||||
emergency: true,
|
||||
},
|
||||
start: true,
|
||||
update: {
|
||||
tags: true,
|
||||
},
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { default as aclPrivilege } from './acl-privilege.mjs'
|
||||
import { default as aclRole } from './acl-role.mjs'
|
||||
import { default as alarm } from './alarm.mjs'
|
||||
import { default as backupArchive } from './backup-archive.mjs'
|
||||
import { default as backupJob } from './backup-job.mjs'
|
||||
import { default as backupLog } from './backup-log.mjs'
|
||||
import { default as backupRepository } from './backup-repository.mjs'
|
||||
import { default as gpuGroup } from './gpuGroup.mjs'
|
||||
import { default as group } from './group.mjs'
|
||||
import { default as host } from './host.mjs'
|
||||
import { default as message } from './message.mjs'
|
||||
import { default as network } from './network.mjs'
|
||||
import { default as pbd } from './pbd.mjs'
|
||||
import { default as pci } from './pci.mjs'
|
||||
import { default as pgpu } from './pgpu.mjs'
|
||||
import { default as pif } from './pif.mjs'
|
||||
import { default as pool } from './pool.mjs'
|
||||
import { default as proxy } from './proxy.mjs'
|
||||
import { default as restoreLog } from './restore-log.mjs'
|
||||
import { default as schedule } from './schedule.mjs'
|
||||
import { default as server } from './server.mjs'
|
||||
import { default as sm } from './sm.mjs'
|
||||
import { default as sr } from './sr.mjs'
|
||||
import { default as task } from './task.mjs'
|
||||
import { default as user } from './user.mjs'
|
||||
import { default as vbd } from './vbd.mjs'
|
||||
import { default as vdiSnapshot } from './vdi-snapshot.mjs'
|
||||
import { default as vdiUnmanaged } from './vdi-unmanaged.mjs'
|
||||
import { default as vdi } from './vdi.mjs'
|
||||
import { default as vgpu } from './vgpu.mjs'
|
||||
import { default as vgpuType } from './vgpuType.mjs'
|
||||
import { default as vif } from './vif.mjs'
|
||||
import { default as vmController } from './vm-controller.mjs'
|
||||
import { default as vmSnapshot } from './vm-snapshot.mjs'
|
||||
import { default as vmTemplate } from './vm-template.mjs'
|
||||
import { default as vm } from './vm.mjs'
|
||||
import { default as vtpm } from './vtpm.mjs'
|
||||
|
||||
/**
|
||||
* value without sub action like 'shutdown' mean: 'shutdown:*' (so shutdown mean you can shutdown:clean and shutdown:hard)
|
||||
*/
|
||||
export const SUPPORTED_ACTIONS_BY_RESOURCE = {
|
||||
'acl-privilege': aclPrivilege,
|
||||
'acl-role': aclRole,
|
||||
alarm,
|
||||
'backup-archive': backupArchive,
|
||||
'backup-job': backupJob,
|
||||
'backup-log': backupLog,
|
||||
'backup-repository': backupRepository,
|
||||
group,
|
||||
gpuGroup,
|
||||
host,
|
||||
message,
|
||||
network,
|
||||
pbd,
|
||||
pci,
|
||||
pgpu,
|
||||
pif,
|
||||
pool,
|
||||
proxy,
|
||||
'restore-log': restoreLog,
|
||||
schedule,
|
||||
server,
|
||||
sm,
|
||||
sr,
|
||||
task,
|
||||
user,
|
||||
vbd,
|
||||
'vdi-snapshot': vdiSnapshot,
|
||||
'vdi-unmanaged': vdiUnmanaged,
|
||||
vdi,
|
||||
vgpu,
|
||||
vgpuType,
|
||||
vif,
|
||||
'vm-controller': vmController,
|
||||
'vm-snapshot': vmSnapshot,
|
||||
'vm-template': vmTemplate,
|
||||
vm,
|
||||
vtpm,
|
||||
} as const
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export default {
|
||||
create: true,
|
||||
delete: true,
|
||||
read: true,
|
||||
update: {
|
||||
tags: true,
|
||||
other_config: true,
|
||||
},
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
plug: true,
|
||||
unplug: true,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
update: {
|
||||
management: true,
|
||||
},
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export default {
|
||||
'add-host': true,
|
||||
create: {
|
||||
network: true,
|
||||
vm: true,
|
||||
},
|
||||
'emergency-shutdown': true,
|
||||
read: true,
|
||||
'rolling-reboot': true,
|
||||
'rolling-update': true,
|
||||
update: {
|
||||
tags: true,
|
||||
},
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
run: true,
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default {
|
||||
connect: true,
|
||||
create: true,
|
||||
delete: true,
|
||||
disconnect: true,
|
||||
read: true,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export default {
|
||||
create: true,
|
||||
delete: true,
|
||||
forget: true,
|
||||
import: {
|
||||
vdi: true,
|
||||
vm: true,
|
||||
},
|
||||
'migrate-receive': true,
|
||||
read: true,
|
||||
'reclaim-space': true,
|
||||
scan: true,
|
||||
update: {
|
||||
tags: true,
|
||||
},
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
abort: true,
|
||||
delete: true,
|
||||
read: true,
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export default {
|
||||
create: true,
|
||||
delete: true,
|
||||
read: true,
|
||||
update: {
|
||||
name: true,
|
||||
password: true,
|
||||
permission: true,
|
||||
preferences: true,
|
||||
},
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default {
|
||||
connect: true,
|
||||
create: true,
|
||||
delete: true,
|
||||
disconnect: true,
|
||||
read: true,
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export default {
|
||||
delete: true,
|
||||
export: true,
|
||||
read: true,
|
||||
update: {
|
||||
tags: true,
|
||||
},
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export default {
|
||||
boot: true,
|
||||
create: true,
|
||||
delete: true,
|
||||
'export-content': true,
|
||||
'import-content': true,
|
||||
'migrate-send': true,
|
||||
read: true,
|
||||
update: {
|
||||
name_description: true,
|
||||
name_label: true,
|
||||
size: true,
|
||||
tags: true,
|
||||
},
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export default {
|
||||
connect: true,
|
||||
create: true,
|
||||
delete: true,
|
||||
disconnect: true,
|
||||
read: true,
|
||||
update: {
|
||||
allowedIpv4Addresses: true,
|
||||
allowedIpv6Addresses: true,
|
||||
lockingMode: true,
|
||||
rateLimit: true,
|
||||
txChecksumming: true,
|
||||
other_config: true,
|
||||
},
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
update: {
|
||||
tags: true,
|
||||
},
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export default {
|
||||
delete: true,
|
||||
export: true,
|
||||
read: true,
|
||||
update: {
|
||||
tags: true,
|
||||
},
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export default {
|
||||
delete: true,
|
||||
export: true,
|
||||
instantiate: true,
|
||||
read: true,
|
||||
update: {
|
||||
tags: true,
|
||||
},
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
export default {
|
||||
clone: true,
|
||||
delete: true,
|
||||
export: true,
|
||||
'migrate-send': true,
|
||||
pause: true,
|
||||
read: true,
|
||||
reboot: {
|
||||
clean: true,
|
||||
hard: true,
|
||||
},
|
||||
resume: true,
|
||||
'revert-snapshot': true,
|
||||
shutdown: {
|
||||
clean: true,
|
||||
hard: true,
|
||||
},
|
||||
snapshot: true,
|
||||
start: true,
|
||||
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,3 +0,0 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import * as CM from 'complex-matcher'
|
||||
import type { XoAclPrivilege, XoAclSupportedActions, XoAclSupportedResource } from '@vates/types/lib/xen-orchestra/acl'
|
||||
|
||||
import { SUPPORTED_ACTIONS_BY_RESOURCE } from '../actions/index.mjs'
|
||||
|
||||
export type SupportedActionsByResource = typeof SUPPORTED_ACTIONS_BY_RESOURCE
|
||||
export type SupportedResource = XoAclSupportedResource<SupportedActionsByResource>
|
||||
export type SupportedActions<T extends SupportedResource> = XoAclSupportedActions<SupportedActionsByResource, T>
|
||||
export type TPrivilege<T extends SupportedResource> = XoAclPrivilege<SupportedActionsByResource, T>
|
||||
|
||||
export class Privilege<T extends SupportedResource> {
|
||||
#action: TPrivilege<T>['action']
|
||||
#selector?: (object: object) => boolean
|
||||
#resource: TPrivilege<T>['resource']
|
||||
#effect: TPrivilege<T>['effect']
|
||||
|
||||
constructor({
|
||||
action,
|
||||
selector,
|
||||
resource,
|
||||
effect,
|
||||
}: {
|
||||
action: TPrivilege<T>['action']
|
||||
selector?: TPrivilege<T>['selector']
|
||||
resource: TPrivilege<T>['resource']
|
||||
effect: TPrivilege<T>['effect']
|
||||
}) {
|
||||
Privilege.checkActionIsValid(resource, action)
|
||||
|
||||
this.#action = action
|
||||
this.#selector = selector !== undefined ? CM.parse(selector).createPredicate() : undefined
|
||||
this.#resource = resource
|
||||
this.#effect = effect
|
||||
}
|
||||
|
||||
get effect() {
|
||||
return this.#effect
|
||||
}
|
||||
|
||||
#matchAction<Resource extends SupportedResource = T>(action: SupportedActions<Resource>) {
|
||||
// read:name_label - read:name_label
|
||||
if (this.#action === action) {
|
||||
return true
|
||||
}
|
||||
|
||||
const [namespaceToMatch, actionToMatch] = action.split(':')
|
||||
const [thisNamespace, thisAction] = this.#action.split(':')
|
||||
|
||||
if (thisNamespace === '*') {
|
||||
return true
|
||||
}
|
||||
|
||||
// update:vm - read:name_label
|
||||
if (thisNamespace !== namespaceToMatch) {
|
||||
return false
|
||||
}
|
||||
|
||||
// read - read:name_label
|
||||
if (thisAction === undefined) {
|
||||
return true
|
||||
}
|
||||
// read:name_decription - read:name_label
|
||||
if (thisAction !== actionToMatch) {
|
||||
return false
|
||||
}
|
||||
|
||||
throw new Error(`Unable to verify if ${this.#action} match ${action} `)
|
||||
}
|
||||
|
||||
#matchSelector(object: object) {
|
||||
if (this.#selector === undefined) {
|
||||
return true
|
||||
}
|
||||
|
||||
return this.#selector(object)
|
||||
}
|
||||
|
||||
#matchResource(resource: string): resource is SupportedResource {
|
||||
return resource === this.#resource
|
||||
}
|
||||
|
||||
match<Resource extends SupportedResource = T>(constraint: {
|
||||
action: SupportedActions<Resource>
|
||||
resource: Resource
|
||||
object: object
|
||||
}): boolean {
|
||||
return (
|
||||
this.#matchResource(constraint.resource) &&
|
||||
this.#matchAction(constraint.action) &&
|
||||
this.#matchSelector(constraint.object)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw if action not supported
|
||||
*/
|
||||
static checkActionIsValid<T extends SupportedResource>(resource: T, action: SupportedActions<T>) {
|
||||
const supportedActions = SUPPORTED_ACTIONS_BY_RESOURCE[resource]
|
||||
if (supportedActions === undefined) {
|
||||
throw new Error(`${resource} resource not supported`)
|
||||
}
|
||||
|
||||
if (action === '*') {
|
||||
return
|
||||
}
|
||||
|
||||
const segments = action.split(':')
|
||||
let _action: object | undefined = undefined
|
||||
segments.forEach(segment => {
|
||||
_action = (_action ?? supportedActions)[segment]
|
||||
|
||||
if (_action === undefined) {
|
||||
throw new Error(
|
||||
`${action} action not supported for the resource: ${resource}. See ${JSON.stringify(supportedActions)}`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// DO NOT EDIT
|
||||
// Overwritten by scripts/generate-types.mjs after running npm run build
|
||||
// scripts/generate-types.mjs will replace this file to automatically generate and export all privileged types.
|
||||
// This makes external use easier because it's not necessary to resolve the type every time.
|
||||
|
||||
import type { Privilege, SupportedResource } from '../index.mjs'
|
||||
|
||||
export type AnyPrivilege = {
|
||||
[Resource in SupportedResource]: Privilege<Resource>
|
||||
}[SupportedResource]
|
||||
@@ -1,167 +0,0 @@
|
||||
import assert from 'node:assert'
|
||||
import type { XoUser } from '@vates/types/xo'
|
||||
|
||||
import { SUPPORTED_ACTIONS_BY_RESOURCE } from './actions/index.mjs'
|
||||
import {
|
||||
Privilege as CPrivilege,
|
||||
TPrivilege as Privilege,
|
||||
SupportedActions,
|
||||
SupportedActionsByResource,
|
||||
SupportedResource,
|
||||
} from './class/privilege.mjs'
|
||||
|
||||
// Export all types constructed using SUPPORTED_ACTIONS_BY_RESOURCE
|
||||
export type { Privilege, SupportedActions, SupportedActionsByResource, SupportedResource }
|
||||
export { SUPPORTED_ACTIONS_BY_RESOURCE }
|
||||
|
||||
export type * from './generated/privilege-types.mjs'
|
||||
|
||||
import { AnyPrivilege } from './generated/privilege-types.mjs'
|
||||
|
||||
export type AnyPrivilegeOnParam = {
|
||||
[Resource in SupportedResource]: {
|
||||
user: XoUser
|
||||
resource: Resource
|
||||
action: SupportedActions<Resource>
|
||||
objects: object | object[]
|
||||
}
|
||||
}[SupportedResource]
|
||||
|
||||
export function hasPrivilegeOn<T extends SupportedResource>({
|
||||
user,
|
||||
action,
|
||||
resource,
|
||||
objects,
|
||||
userPrivileges,
|
||||
}: {
|
||||
user: XoUser
|
||||
resource: T
|
||||
action: SupportedActions<T>
|
||||
objects: object | object[]
|
||||
userPrivileges: AnyPrivilege[]
|
||||
}) {
|
||||
// Function that will be called outside of the module
|
||||
// We cannot be sure types are respected
|
||||
assert.strictEqual(typeof user?.permission, 'string')
|
||||
assert.strictEqual(typeof action, 'string')
|
||||
assert.strictEqual(typeof resource, 'string')
|
||||
assert.strictEqual(objects === undefined, false)
|
||||
|
||||
CPrivilege.checkActionIsValid(resource, action as SupportedActions<typeof resource>)
|
||||
|
||||
if (user.permission === 'admin') {
|
||||
return true
|
||||
}
|
||||
|
||||
const effectsByObject = new Map<object, Privilege<T>['effect'][]>()
|
||||
if (Array.isArray(objects)) {
|
||||
objects.forEach(obj => effectsByObject.set(obj, []))
|
||||
} else {
|
||||
effectsByObject.set(objects, [])
|
||||
}
|
||||
|
||||
for (const userPrivilege of userPrivileges) {
|
||||
const privilege = new CPrivilege(userPrivilege as Privilege<typeof userPrivilege.resource>)
|
||||
|
||||
for (const [object, effects] of effectsByObject.entries()) {
|
||||
if (!privilege.match({ action, resource, object })) {
|
||||
continue
|
||||
}
|
||||
effects.push(privilege.effect)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
Array.from(effectsByObject.values()).some(
|
||||
effects => effects.length === 0 || effects.some(effect => effect === 'deny')
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function getMissingPrivileges(params: AnyPrivilegeOnParam[], userPrivileges: AnyPrivilege[]) {
|
||||
return params
|
||||
.filter(
|
||||
param =>
|
||||
!hasPrivilegeOn({
|
||||
user: param.user,
|
||||
resource: param.resource,
|
||||
action: param.action as SupportedActions<typeof param.resource>,
|
||||
objects: param.objects,
|
||||
userPrivileges,
|
||||
})
|
||||
)
|
||||
.map(({ action, objects, resource }) => {
|
||||
let objectIds: unknown[] | undefined
|
||||
let objectId: unknown | undefined
|
||||
|
||||
if (Array.isArray(objects)) {
|
||||
if (objects.length > 1) {
|
||||
objectIds = []
|
||||
objects.forEach(obj => {
|
||||
if ('id' in obj) {
|
||||
objectIds!.push(obj.id)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
objects = objects?.[0] ?? {}
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(objects)) {
|
||||
objectId = 'id' in objects ? objects.id : undefined
|
||||
}
|
||||
|
||||
return {
|
||||
objectId,
|
||||
objectIds,
|
||||
action,
|
||||
resource,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function hasPrivileges(params: AnyPrivilegeOnParam[], userPrivileges: AnyPrivilege[]) {
|
||||
return getMissingPrivileges(params, userPrivileges).length === 0
|
||||
}
|
||||
|
||||
export function filterObjectsWithPrivilege<Resource extends SupportedResource, Object extends object>(param: {
|
||||
user: XoUser
|
||||
resource: Resource
|
||||
action: SupportedActions<Resource>
|
||||
objects: Object[]
|
||||
userPrivileges: AnyPrivilege[]
|
||||
}) {
|
||||
return param.objects.filter(obj => hasPrivilegeOn({ ...param, objects: obj }))
|
||||
}
|
||||
|
||||
// Utils
|
||||
|
||||
/**
|
||||
* Mirror GetKeysRecursively from @vates/types
|
||||
*/
|
||||
export function getActionStrings<Resource extends SupportedResource, Actions = SupportedActions<Resource>>(
|
||||
resource: Resource
|
||||
): Actions[] {
|
||||
type NestedActions = { [key: string]: boolean | NestedActions }
|
||||
|
||||
function collectActions(obj: NestedActions, prefix?: Actions): Actions[] {
|
||||
const actions: Actions[] = ['*' as Actions]
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const action = (prefix !== undefined ? `${prefix}:${key}` : key) as Actions
|
||||
actions.push(action)
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
continue
|
||||
}
|
||||
|
||||
actions.push(...collectActions(value, action))
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
return collectActions(SUPPORTED_ACTIONS_BY_RESOURCE[resource])
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
import { test, suite } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import type { Branded } from '@vates/types/common'
|
||||
import type { XoUser } from '@vates/types/xo'
|
||||
|
||||
import { Privilege } from '../class/privilege.mjs'
|
||||
import type { Privilege as TPrivilege } from '../index.mjs'
|
||||
import {
|
||||
filterObjectsWithPrivilege,
|
||||
getActionStrings,
|
||||
getMissingPrivileges,
|
||||
hasPrivilegeOn,
|
||||
hasPrivileges,
|
||||
} from '../index.mjs'
|
||||
|
||||
suite('Privilege.checkActionIsValid behavior', () => {
|
||||
test('throw on invalid resource', () => {
|
||||
// @ts-expect-error test invalid action
|
||||
assert.throws(() => Privilege.checkActionIsValid('foo', 'read'))
|
||||
})
|
||||
|
||||
test('throw on invalid action', () => {
|
||||
// @ts-expect-error test invalid action
|
||||
assert.throws(() => Privilege.checkActionIsValid('vm', 'foo:vm'))
|
||||
})
|
||||
|
||||
test('should work', () => {
|
||||
assert.doesNotThrow(() => Privilege.checkActionIsValid('vm', 'read'))
|
||||
})
|
||||
})
|
||||
|
||||
suite('Privilege.match behavior', () => {
|
||||
const privilege = new Privilege({ action: 'read', resource: 'vm', effect: 'allow' })
|
||||
const object = { id: 1, name_label: 'foo' }
|
||||
|
||||
test('Should not match because of the action', () => {
|
||||
assert.strictEqual(privilege.match({ resource: 'vm', action: 'start', object }), false)
|
||||
})
|
||||
|
||||
test('Should not match because of the resource', () => {
|
||||
assert.strictEqual(privilege.match({ resource: 'host', action: 'read', object }), false)
|
||||
})
|
||||
|
||||
test('Should match', () => {
|
||||
assert.strictEqual(privilege.match({ resource: 'vm', action: 'read', object }), true)
|
||||
})
|
||||
|
||||
test('Should match even if the action is not equal to the action privilege', () => {
|
||||
const shutdownPrivilege = new Privilege({ action: 'shutdown', resource: 'vm', effect: 'allow' })
|
||||
assert.strictEqual(shutdownPrivilege.match({ resource: 'vm', action: 'shutdown:clean', object }), true)
|
||||
})
|
||||
|
||||
test('Should match because the action is *', () => {
|
||||
const vmPrivilege = new Privilege({ action: '*', resource: 'vm', effect: 'allow' })
|
||||
assert.strictEqual(vmPrivilege.match({ action: 'reboot:hard', resource: 'vm', object: object }), true)
|
||||
})
|
||||
|
||||
test('Should not match due to insufficient privilege', () => {
|
||||
const cleanShutdownPrivilege = new Privilege({ action: 'shutdown:clean', resource: 'vm', effect: 'allow' })
|
||||
assert.strictEqual(cleanShutdownPrivilege.match({ resource: 'vm', action: 'shutdown', object }), false)
|
||||
})
|
||||
})
|
||||
|
||||
suite('ACL V2 behavior', async () => {
|
||||
// === Setup ===
|
||||
const admin = {
|
||||
id: '1' as XoUser['id'],
|
||||
groups: [],
|
||||
permission: 'admin',
|
||||
email: 'admin@admin.net',
|
||||
preferences: {},
|
||||
} satisfies XoUser
|
||||
|
||||
const user = {
|
||||
id: '2' as XoUser['id'],
|
||||
groups: [],
|
||||
permission: 'none',
|
||||
email: 'user@user.net',
|
||||
preferences: {},
|
||||
} satisfies XoUser
|
||||
|
||||
const privilegeId = 'fake-priv-test-id' as Branded<'acl-v2-privilege'>
|
||||
const roleId = 'fake-role-test-id' as Branded<'acl-v2-role'>
|
||||
|
||||
// === Objects
|
||||
const poolWithoutPrivilege = { id: 'pool-id' }
|
||||
const alpineVm = { id: 'alpine', creation: { creator: admin.id } }
|
||||
const almaVm = { id: 'alma', creation: { creator: user.id } }
|
||||
const xoaVm = { id: 'xoa' }
|
||||
const template = { id: 'template-id', $pool: '1' }
|
||||
const templateDeny = { id: 'template-deny-id', $pool: '2' }
|
||||
const vdis = [{ $SR: '2' }, { $SR: '2' }]
|
||||
const vdisMultipleSrs = [{ $SR: '1' }, { $SR: '2' }]
|
||||
const vifs = [{ $network: '1' }]
|
||||
const iso = { id: 'iso-vdi-id', $SR: '3' }
|
||||
const hostAffinity = { id: 'affinity-host-id', $pool: '1' }
|
||||
|
||||
// === DB Privileges
|
||||
const vmRead = { id: privilegeId, action: 'read', resource: 'vm', effect: 'allow', roleId } satisfies TPrivilege<'vm'>
|
||||
const vmCleanShutdown = {
|
||||
id: privilegeId,
|
||||
action: 'shutdown:clean',
|
||||
resource: 'vm',
|
||||
selector: `creation:creator:${user.id}`,
|
||||
effect: 'allow',
|
||||
roleId,
|
||||
} satisfies TPrivilege<'vm'>
|
||||
const xoaVmDenyRead = {
|
||||
id: privilegeId,
|
||||
action: 'read',
|
||||
resource: 'vm',
|
||||
effect: 'deny',
|
||||
selector: `id: ${xoaVm.id}`,
|
||||
roleId,
|
||||
} satisfies TPrivilege<'vm'>
|
||||
const vdiCreate = {
|
||||
id: privilegeId,
|
||||
action: 'create',
|
||||
resource: 'vdi',
|
||||
selector: '$SR:2',
|
||||
effect: 'allow',
|
||||
roleId,
|
||||
} satisfies TPrivilege<'vdi'>
|
||||
const vifCreate = {
|
||||
id: privilegeId,
|
||||
action: 'create',
|
||||
resource: 'vif',
|
||||
effect: 'allow',
|
||||
roleId,
|
||||
} satisfies TPrivilege<'vif'>
|
||||
const templateInstantiate = {
|
||||
id: privilegeId,
|
||||
action: 'instantiate',
|
||||
resource: 'vm-template',
|
||||
effect: 'allow',
|
||||
roleId,
|
||||
} satisfies TPrivilege<'vm-template'>
|
||||
const templateInstantiateDeny = {
|
||||
id: privilegeId,
|
||||
action: 'instantiate',
|
||||
resource: 'vm-template',
|
||||
selector: '$pool:2',
|
||||
effect: 'deny',
|
||||
roleId,
|
||||
} satisfies TPrivilege<'vm-template'>
|
||||
const isoVdiUse = {
|
||||
id: privilegeId,
|
||||
action: 'boot',
|
||||
resource: 'vdi',
|
||||
selector: '$SR:3',
|
||||
effect: 'allow',
|
||||
roleId,
|
||||
} satisfies TPrivilege<'vdi'>
|
||||
const hostVm = {
|
||||
id: privilegeId,
|
||||
action: 'allow-vm',
|
||||
resource: 'host',
|
||||
effect: 'allow',
|
||||
roleId,
|
||||
} satisfies TPrivilege<'host'>
|
||||
|
||||
const allPrivileges = [
|
||||
vmRead,
|
||||
vmCleanShutdown,
|
||||
xoaVmDenyRead,
|
||||
vdiCreate,
|
||||
vifCreate,
|
||||
templateInstantiate,
|
||||
templateInstantiateDeny,
|
||||
isoVdiUse,
|
||||
hostVm,
|
||||
]
|
||||
|
||||
// === Test ===
|
||||
suite('hasPrivilegeOn behavior', () => {
|
||||
test('Should always return true for admin', () => {
|
||||
assert.strictEqual(
|
||||
hasPrivilegeOn<'pool'>({
|
||||
user: admin,
|
||||
action: 'read',
|
||||
resource: 'pool',
|
||||
objects: poolWithoutPrivilege,
|
||||
userPrivileges: allPrivileges,
|
||||
}),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Should return false by default for non admin', () => {
|
||||
assert.strictEqual(
|
||||
hasPrivilegeOn({
|
||||
user,
|
||||
action: 'read',
|
||||
resource: 'pool',
|
||||
objects: poolWithoutPrivilege,
|
||||
userPrivileges: allPrivileges,
|
||||
}),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('Should return true if user has privilege', () => {
|
||||
assert.strictEqual(
|
||||
hasPrivilegeOn({ user, action: 'read', resource: 'vm', objects: alpineVm, userPrivileges: allPrivileges }),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Should return false if user have a both deny and allow privileges', () => {
|
||||
assert.strictEqual(
|
||||
hasPrivilegeOn({ user, action: 'read', resource: 'vm', objects: xoaVm, userPrivileges: allPrivileges }),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('Should handle multiple and single object', () => {
|
||||
assert.strictEqual(
|
||||
hasPrivilegeOn({
|
||||
user,
|
||||
action: 'read',
|
||||
resource: 'vm',
|
||||
objects: [alpineVm, almaVm],
|
||||
userPrivileges: allPrivileges,
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.strictEqual(
|
||||
hasPrivilegeOn({
|
||||
user,
|
||||
action: 'read',
|
||||
resource: 'vm',
|
||||
objects: [alpineVm, almaVm, xoaVm],
|
||||
userPrivileges: allPrivileges,
|
||||
}),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('Should allow clean shutdown only own VMs', () => {
|
||||
assert.strictEqual(
|
||||
hasPrivilegeOn({
|
||||
user,
|
||||
action: 'shutdown:clean',
|
||||
resource: 'vm',
|
||||
objects: almaVm,
|
||||
userPrivileges: allPrivileges,
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.strictEqual(
|
||||
hasPrivilegeOn({
|
||||
user,
|
||||
action: 'shutdown:clean',
|
||||
resource: 'vm',
|
||||
objects: [almaVm, alpineVm],
|
||||
userPrivileges: allPrivileges,
|
||||
}),
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
suite('getMissingPrivileges behavior', () => {
|
||||
test('Should return empty array if no missing privileges', () => {
|
||||
const missingPrivileges = getMissingPrivileges(
|
||||
[
|
||||
{ user, action: 'shutdown:clean', resource: 'vm', objects: almaVm },
|
||||
{ user, action: 'instantiate', resource: 'vm-template', objects: template },
|
||||
{ user, action: 'create', resource: 'vdi', objects: vdis },
|
||||
{ user, action: 'create', resource: 'vif', objects: vifs },
|
||||
{ user, action: 'boot', resource: 'vdi', objects: iso },
|
||||
{ user, action: 'allow-vm', resource: 'host', objects: hostAffinity },
|
||||
],
|
||||
allPrivileges
|
||||
)
|
||||
|
||||
assert.strictEqual(missingPrivileges.length, 0)
|
||||
})
|
||||
|
||||
test('Should return only missing privileges', () => {
|
||||
const missingPrivileges = getMissingPrivileges(
|
||||
[
|
||||
{ user, action: 'instantiate', resource: 'vm-template', objects: template },
|
||||
{ user, action: 'create', resource: 'vdi', objects: vdis },
|
||||
{ user, action: 'create', resource: 'vif', objects: vifs },
|
||||
{ user, action: 'boot', resource: 'vdi', objects: iso },
|
||||
{ user, action: 'allow-vm', resource: 'host', objects: hostAffinity },
|
||||
{ user, action: 'read', resource: 'pool', objects: poolWithoutPrivilege },
|
||||
{ user, action: '*', resource: 'pool', objects: poolWithoutPrivilege },
|
||||
],
|
||||
allPrivileges
|
||||
)
|
||||
|
||||
assert.strictEqual(missingPrivileges.length, 2)
|
||||
})
|
||||
|
||||
test('Should return objectId if only one object in the missing privilege', () => {
|
||||
const missingPrivileges = getMissingPrivileges(
|
||||
[{ user, action: 'read', resource: 'pool', objects: poolWithoutPrivilege }],
|
||||
allPrivileges
|
||||
)
|
||||
|
||||
assert.strictEqual(missingPrivileges.length, 1)
|
||||
assert.notStrictEqual(missingPrivileges[0]!.objectId, undefined)
|
||||
})
|
||||
|
||||
test('Should return objectIds if multiple objects in the missing privilege', () => {
|
||||
const missingPrivileges = getMissingPrivileges(
|
||||
[{ user, action: 'read', resource: 'pool', objects: [poolWithoutPrivilege, poolWithoutPrivilege] }],
|
||||
allPrivileges
|
||||
)
|
||||
|
||||
assert.strictEqual(missingPrivileges.length, 1)
|
||||
assert.notStrictEqual(missingPrivileges[0]!.objectIds, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
suite('hasPrivileges behavior', () => {
|
||||
test('Should return false if at least one privilege deny', () => {
|
||||
const result = hasPrivileges(
|
||||
[
|
||||
{ user, action: 'instantiate', resource: 'vm-template', objects: templateDeny },
|
||||
{ user, action: 'create', resource: 'vdi', objects: vdis },
|
||||
{ user, action: 'create', resource: 'vif', objects: vifs },
|
||||
{ user, action: 'boot', resource: 'vdi', objects: iso },
|
||||
{ user, action: 'allow-vm', resource: 'host', objects: hostAffinity },
|
||||
],
|
||||
allPrivileges
|
||||
)
|
||||
assert.strictEqual(result, false)
|
||||
})
|
||||
test("Should return false if at least one privilege didn't match", () => {
|
||||
const result = hasPrivileges(
|
||||
[
|
||||
{ user, action: 'instantiate', resource: 'vm-template', objects: template },
|
||||
{ user, action: 'create', resource: 'vdi', objects: vdisMultipleSrs },
|
||||
{ user, action: 'create', resource: 'vif', objects: vifs },
|
||||
{ user, action: 'boot', resource: 'vdi', objects: iso },
|
||||
{ user, action: 'allow-vm', resource: 'host', objects: hostAffinity },
|
||||
],
|
||||
allPrivileges
|
||||
)
|
||||
assert.strictEqual(result, false)
|
||||
})
|
||||
})
|
||||
|
||||
suite('filterObjectsWithPrivilege behavior', () => {
|
||||
test('Should return only objects that match privileges', () => {
|
||||
let vms = [almaVm, alpineVm, xoaVm, xoaVm]
|
||||
assert.strictEqual(vms.length, 4)
|
||||
vms = filterObjectsWithPrivilege({
|
||||
user,
|
||||
action: 'read',
|
||||
resource: 'vm',
|
||||
objects: vms,
|
||||
userPrivileges: allPrivileges,
|
||||
})
|
||||
assert.strictEqual(vms.length, 2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
suite('getActionStrings', () => {
|
||||
test('returns flat actions for a resource without nesting', () => {
|
||||
const actions = getActionStrings('host')
|
||||
assert.ok(actions.includes('read'))
|
||||
// @ts-ignore A TypeScript error is thrown because `foo` is not of type `SupportedActions<'vm'>`.
|
||||
// To test for a negative value, the error is ignored.
|
||||
assert.strictEqual(actions.includes('foo'), false)
|
||||
})
|
||||
|
||||
test('parent action is included alongside its children', () => {
|
||||
const actions = getActionStrings('vm')
|
||||
assert.ok(actions.includes('shutdown'))
|
||||
assert.ok(actions.includes('shutdown:clean'))
|
||||
assert.ok(actions.includes('shutdown:hard'))
|
||||
})
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
|
||||
"strict": true,
|
||||
"module": "nodenext",
|
||||
"lib": ["ESNext"],
|
||||
"moduleResolution": "nodenext",
|
||||
"target": "ES2022",
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"], // Only use types from Node.js and packages that are explicitly imported
|
||||
"noImplicitAny": false, // otherwise it will throw an error when importing packages without type definition
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
"exclude": ["./node_modules", "./dist"]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/async-map",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.2",
|
||||
"license": "ISC",
|
||||
"description": "Promise.all + map for all iterables",
|
||||
"keywords": [
|
||||
@@ -28,7 +28,7 @@
|
||||
"node": ">=6"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.18.0"
|
||||
"lodash": "^4.17.4"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/decorate-with": "^2.1.0",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"golike-defer": "^0.5.1",
|
||||
"object-hash": "^2.0.1"
|
||||
},
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../scripts/npmignore
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user