Compare commits

..

5 Commits

Author SHA1 Message Date
Sebastian CZECH
d62433148b update 2025-05-07 16:08:49 +02:00
Sebastian CZECH
d44364d4d5 update 2025-05-07 14:08:15 +02:00
Sebastian CZECH
9118dc0835 update 2025-05-07 14:05:35 +02:00
Sebastian CZECH
97bf035a9f update 2025-05-07 14:03:28 +02:00
Sebastian CZECH
1e7ac62c51 feat(web-core): create task item component 2025-04-30 03:17:11 +02:00
2191 changed files with 49534 additions and 128819 deletions

View File

@@ -171,6 +171,7 @@ module.exports = {
'vue/no-empty-component-block': 'error',
'vue/no-multiple-objects-in-class': 'error',
'vue/no-ref-object-reactivity-loss': 'error',
'vue/no-required-prop-with-default': 'error',
'vue/no-static-inline-styles': 'error',
'vue/no-template-target-blank': 'error',
'vue/no-undef-components': ['error', { ignorePatterns: ['RouterLink', 'RouterView', 'I18nT'] }],
@@ -216,16 +217,7 @@ module.exports = {
'@intlify/vue-i18n/no-raw-text': [
'error',
{
ignoreText: [
'Xen Orchestra Lite',
'Xen Orchestra',
'Xen Orchestra Appliance',
'XO Lite',
'XCP-ng',
'XO 5',
'404',
'⚠️',
],
ignoreText: ['Xen Orchestra Lite', 'Xen Orchestra', 'XO Lite', 'XCP-ng', 'XO 5', '404', '⚠️'],
},
],
'@intlify/vue-i18n/no-v-html': 'error',
@@ -233,29 +225,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'] },
],
},
},
@@ -290,17 +260,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/'],

View File

@@ -40,8 +40,8 @@ body:
- type: markdown
attributes:
value: |
- Don't forget to [read this first](https://docs.xen-orchestra.com/community)
- As well as follow [this guide](https://docs.xen-orchestra.com/community#report-a-bug)
- Don't forget to [read this first](https://xen-orchestra.com/docs/community.html)
- As well as follow [this guide](https://xen-orchestra.com/docs/community.html#report-a-bug)
- type: input
id: xo-sources-commit-number
attributes:

View File

@@ -1,4 +1,4 @@
name: Continuous Integration
name: Continous Integration
on: push
jobs:
CI:
@@ -25,7 +25,7 @@ jobs:
- name: Setup Node environment
uses: actions/setup-node@v4
with:
node-version: '24.14'
node-version: '20'
cache: 'yarn'
- name: Install project dependencies

View File

@@ -16,7 +16,7 @@ Returns a promise wich rejects as soon as a call to `iteratee` throws or a promi
- `concurrency`: a number which indicates the maximum number of parallel call to `iteratee`, defaults to `10`. The value `0` means no concurrency limit.
- `signal`: an abort signal to stop the iteration
- `stopOnError`: whether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
- `stopOnError`: wether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
```js
import { asyncEach } from '@vates/async-each'

View File

@@ -34,7 +34,7 @@ Returns a promise wich rejects as soon as a call to `iteratee` throws or a promi
- `concurrency`: a number which indicates the maximum number of parallel call to `iteratee`, defaults to `10`. The value `0` means no concurrency limit.
- `signal`: an abort signal to stop the iteration
- `stopOnError`: whether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
- `stopOnError`: wether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
```js
import { asyncEach } from '@vates/async-each'

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
* @param {Array|Buffer|string} data1 - The first data array or buffer to compare.
* @param {T} data2 - The second data array or buffer to compare.
* @param {(index: number, diff: T) => void} [cb] - The callback function to invoke for each difference. If not provided, an array of differences will be returned.
* @returns {Array<number|T>|undefined} - An array of differences if no callback is provided; otherwise, undefined.
* @returns {Array<number|T>|undefined} - An array of differences if no callback is provided, otherwise undefined.
*/
module.exports = function diff(data1, data2, cb) {
let result

View File

@@ -14,7 +14,7 @@ import { deduped } from '@vates/disposable/deduped'
// the connection with the passed host will be established once at the first call, then, it will be shared with the next calls
const getConnection = deduped(async function (host)) {
const connection = new Connection(host)
return new Disposable(connection, () => connection.close())
return new Disposabe(connection, () => connection.close())
}, host => [host])
```

View File

@@ -32,7 +32,7 @@ import { deduped } from '@vates/disposable/deduped'
// the connection with the passed host will be established once at the first call, then, it will be shared with the next calls
const getConnection = deduped(async function (host)) {
const connection = new Connection(host)
return new Disposable(connection, () => connection.close())
return new Disposabe(connection, () => connection.close())
}, host => [host])
```

View File

@@ -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": {

View File

@@ -28,7 +28,7 @@ fs.stat('autoexec.bat', function (e, stats) {
## API
- `fs = fatfs.createFileSystem(vol, [opts], [cb])` — Simply pass in a block driver (see below) mapped to a FAT partition somewhere, and get back an instance of an [`fs`](http://nodejs.org/api/fs.html). An options dictionary can be provided, details are in the next section. You may also optionally provide a callback `cb(err)` which will be automatically registered for the on `'ready'` or `'error'` event.
- `fs = fatfs.createFileSystem(vol, [opts], [cb])` — Simply pass in a block driver (see below) mapped to a FAT partition somewhere, and get back the API documented [here](http://nodejs.org/api/fs.html). An options dictionary can be provided, details are in the next section. You may also optionally provide a callback `cb(err)` which will be automatically registered for the on `'ready'` or `'error'` event.
- `'ready'` event — fired on `fs` when initial volume information has been determined and the API is ready to use. It is safe to call other `fs` methods before this fires **only if** you are sure the first sector will be readable and represents a valid FAT volume.
- `'error'` event — fired if initialization fails for whatever reason.
@@ -36,7 +36,7 @@ fs.stat('autoexec.bat', function (e, stats) {
The `opts` dictionary you pass to `fatfs.createFileSystem` can contain any of the following options:
- `ro` — Enables readonly mode if `true`. It defaults to `false`, but if your volume driver does not provide a `writeSectors` method it will be overridden to `true`.
- `ro` — Enables readonly mode if `true`. It defaults to `false`, but if your volume driver does not provide a `writeSectors` method it will be overriden to `true`.
- `noatime` — The FAT filesystem can track the last access time (just a date, actually) but this means every read operation would also incur some write overhead. Defaults to `true`, meaning by default access times will **not** be updated on reads. Set this to `false` to track access times.
- `modmode` — chooses how `fs.chmod` (and the mode field from `fs.stat`–family calls) should map FAT attributes to POSIX permissions. Set to the number `0111` to map the readonly flag to the user's write bit being unset, and the archive/system/hidden flags to the user/group/other executable bits respectively. Set to the number `07000` to map the readonly flag to _all_ write bits being unset, and the archive/system/hidden flags to the sticky/setgid/setuid bits respectively. Set to `null` for readonly mapping. Defaults to `0111`.
- `umask` — any bits _set_ in this octal number will be _unset_ in the 'mode' field from `fs.stat`–family calls. It does not affect anything else. Defaults to `process.umask()`, or `0022` if that is unavailable.
@@ -53,7 +53,7 @@ Well, sort of…
### Temporary
- **BETA** **BETA** **BETA**. Seriously, this is a _brand new_, _from scratch_, _completely unproven_ filesystem implementation. It does not have full automated test coverage, and it has not been manually tested very much either. Please, please, please, **make sure you have a backup** of any important drive/image/card you unleash this upon.
- **BETA** **BETA** **BETA**. Seriously, this is a _brand new_, _from scratch_, _completely unproven_ filesystem implementation. It does not have full automated test coverage, and it has not been manually tested very much either. Please please please **make sure you have a backup** of any important drive/image/card you unleash this upon.
- A few methods are not quite implemented, either: `fs.rename`, `fs.unlink` and `fs.rmdir`, as well as `fs.watchFile`/`fs.unwatchFile` and `fs.watch`. These are Coming Soon™.
- There are several internal housekeeping items (redundant FAT tables, extra FAT32 information, etc.) that are not done. These do not seem to affect interop, but you may see warnings when repairing a filesystem written by this module.
- Oh, and not to scare you, but if an IO error happens while writing, the library usually just bails — bubbling an error up to your callback as if it were a hot potato. Although some attempt has been made to do separate writes in the safest order (e.g. allocating an additional file cluster, then appending data into it, and then finally updating the file's size), but this behavior has not been thoroughly audited for all operations. There's certainly no attempt to retry/cleanup/rollback if a multi-step change runs into trouble partway through.

View File

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

View File

@@ -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": {

View File

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

View File

@@ -29,7 +29,7 @@ The `remoteUrl` can be found using the following command from the XOA CLI, and t
`xo-server-db ls remote`
The `vhdPathInRemote` follows a file structure described in [VM backups](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/README.md).
The `vhdPathInRemote` follows a file structure described [here](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/README.md).
## Restore a file from a VHD using `fuse-vhd` CLI
@@ -42,7 +42,7 @@ The `vhdPathInRemote` follows a file structure described in [VM backups](https:/
`partx --bytes --output=NR,START,SIZE,NAME,UUID,TYPE --pairs <mountedVhdPath>`
4. Mount the disk. Depending on the partition type, you may need to add some additional options. The `norecovery` option is used for ext3/ext4/xfs file systems; otherwise, remove this option.
4. Mount the disk. Depending on the partition type, you may need to add some additionnal options. The `norecovery` option is used for ext3/ext4/xfs file systems, otherwise remove this option.
`mount --options=loop,ro,norecovery,sizelimit=<SIZE>,offset=<START*512> --source=<mountedVhdPath> --target=<diskMountPoint>`

View File

@@ -45,7 +45,7 @@ The `remoteUrl` can be found using the following command from the XOA CLI, and t
`xo-server-db ls remote`
The `vhdPathInRemote` follows a file structure described in [VM backups](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/README.md).
The `vhdPathInRemote` follows a file structure described [here](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/README.md).
## Restore a file from a VHD using `fuse-vhd` CLI
@@ -58,7 +58,7 @@ The `vhdPathInRemote` follows a file structure described in [VM backups](https:/
`partx --bytes --output=NR,START,SIZE,NAME,UUID,TYPE --pairs <mountedVhdPath>`
4. Mount the disk. Depending on the partition type, you may need to add some additional options. The `norecovery` option is used for ext3/ext4/xfs file systems; otherwise, remove this option.
4. Mount the disk. Depending on the partition type, you may need to add some additionnal options. The `norecovery` option is used for ext3/ext4/xfs file systems, otherwise remove this option.
`mount --options=loop,ro,norecovery,sizelimit=<SIZE>,offset=<START*512> --source=<mountedVhdPath> --target=<diskMountPoint>`

View File

@@ -19,12 +19,12 @@
},
"main": "./index.mjs",
"dependencies": {
"@xen-orchestra/fs": "^4.8.0",
"@xen-orchestra/log": "^0.7.2",
"@xen-orchestra/fs": "^4.5.0",
"@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.0"
"vhd-lib": "^4.11.3"
},
"bin": {
"xo-fuse-vhd": "./cli.mjs"

View File

@@ -30,14 +30,14 @@ const wrappedGenerator = new Throttle(sourceGenerator, speed)
### Synchronized
Fork a generator. The rules are:
Fork a generator. The rules ares:
- if the source returns, all the forks return
- if the forks error, all the forks error with the same error
- if a fork returns, it is stopped, but the generator continue with the other
- if a fork errors, it is stopped, but the generator continue with the other
- if all the forks return, the source is stopped
- if all the forks error, the source is errored with the last error
- if the source returns, all the forks returns
- if the forks errors, all the forks errors with the same error
- if a the fork return , it is stopped, but the generator continue with the other
- if a the fork error , it is stopped, but the generator continue with the other
- if all the fork return , the source is stopped
- if all the fork error , the source is errored with the last error
- the source start producing a packet when the fastest forked ask for it
- the source forks get the packet only when all the forks asked for it, no buffer stores in memory

View File

@@ -46,14 +46,14 @@ const wrappedGenerator = new Throttle(sourceGenerator, speed)
### Synchronized
Fork a generator. The rules are:
Fork a generator. The rules ares:
- if the source returns, all the forks return
- if the forks error, all the forks error with the same error
- if a fork returns, it is stopped, but the generator continue with the other
- if a fork errors, it is stopped, but the generator continue with the other
- if all the forks return, the source is stopped
- if all the forks error, the source is errored with the last error
- if the source returns, all the forks returns
- if the forks errors, all the forks errors with the same error
- if a the fork return , it is stopped, but the generator continue with the other
- if a the fork error , it is stopped, but the generator continue with the other
- if all the fork return , the source is stopped
- if all the fork error , the source is errored with the last error
- the source start producing a packet when the fastest forked ask for it
- the source forks get the packet only when all the forks asked for it, no buffer stores in memory

View File

@@ -1,13 +1,12 @@
{
"name": "@vates/generator-toolbox",
"version": "1.1.2",
"version": "1.0.1",
"main": "dist/index.mjs",
"license": "MIT",
"private": false,
"type": "module",
"devDependencies": {
"@eslint/js": "^9.19.0",
"rimraf": "^6.0.1",
"@types/node": "^20.6",
"typescript": "~5.6",
"typescript-eslint": "^8.23.0"
@@ -16,10 +15,7 @@
"build": "tsc",
"dev": "tsc --watch",
"postversion": "npm publish --access public",
"prebuild": "rimraf dist/",
"predev": "yarn run prebuild",
"test": "tsc && node --test **/*.test.mjs",
"clean": "rimraf dist/"
"test": "tsc && node --test **/*.test.mjs"
},
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/generator-toolbox",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",

View File

@@ -17,10 +17,8 @@ export class Synchronized<T, TReturn, TNext> {
this.#source = source
}
fork(uid: string): AsyncGenerator<T, TReturn, TNext> {
fork(uid: string): AsyncGenerator {
assert.strictEqual(this.#started, false, `can't create a fork after consuming the data`)
assert.strictEqual(this.#forks.has(uid), false, `a fork with id ${uid} already exists`)
const fork = new Forked<T, TReturn, TNext>(this, uid)
this.#forks.set(uid, fork)
return fork
@@ -85,7 +83,7 @@ export class Synchronized<T, TReturn, TNext> {
// already removed
return { done: true, value: undefined }
}
throw new Error(`trying to remove fork with uid ${uid} that is not a fork of this one`)
throw new Error(`trying to remove fork wih uid ${uid} that is not a fork of this one`)
}
this.#forks.delete(uid)
this.#waitingForks.delete(uid)
@@ -144,7 +142,7 @@ class Forked<T, TReturn, TNext> implements AsyncGenerator<T, TReturn, TNext> {
return this.#parent.remove(this.#uid, e)
}
[Symbol.asyncIterator](): AsyncGenerator<T, TReturn, TNext> {
[Symbol.asyncIterator](): AsyncGenerator<T> {
return this
}
}

View File

@@ -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)
@@ -162,12 +139,4 @@ suite('error', () => {
await first.next()
assert.throws(() => forker.fork('second'))
})
test('It should not allow fork with the same uid', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(5, progress)
const forker = new Synchronized(generator)
forker.fork('first')
forker.fork('second')
assert.throws(() => forker.fork('first'))
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,91 +0,0 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/http-server-plus
> Augmented &#x60;http.Server&#x60;, HTTP/HTTPS/HTTP2 and multiple ports on the same instance
## 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)

View File

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

View File

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

View File

@@ -1,37 +0,0 @@
{
"name": "@vates/http-server-plus",
"version": "2.0.0",
"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"
},
"private": true,
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
}
}

View File

@@ -1,4 +1,4 @@
The use for this library is to create a composite key from a JSON value, ignoring objects identity and properties order.
The use for this librariy is to create a composite key from a JSON value, ignoring objects identity and properties order.
The hash algorithm used is intended to be fast and with low collisions and is not guaranteed to be be secure.

View File

@@ -16,7 +16,7 @@ npm install --save @vates/json-hash
## Usage
The use for this library is to create a composite key from a JSON value, ignoring objects identity and properties order.
The use for this librariy is to create a composite key from a JSON value, ignoring objects identity and properties order.
The hash algorithm used is intended to be fast and with low collisions and is not guaranteed to be be secure.

View File

@@ -1,132 +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
constructor(nbdInfos, blockSize, { dataMap } = {}) {
super()
this.#blockSize = blockSize
this.#nbdInfos = nbdInfos
this.#dataMap = dataMap && this.#processDatamap(dataMap)
}
#processDatamap(rawDataMap) {
return rawDataMap
.filter(({ type }) => type === 0)
.map(({ offset, length }) => ({ offset, length }))
.sort(({ offset: offset1 }, { offset: offset2 }) => offset1 - offset2)
}
/**
* @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)
}
/**
* @param {number} index
* @returns {boolean}
*/
hasBlock(index) {
if (!this.#dataMap) {
throw new Error("can't hasBlock before init")
}
const blockStart = index * this.getBlockSize()
const blockEnd = (index + 1) * this.getBlockSize()
return this.#dataMap.some(({ offset, length }) => offset + length > blockStart && offset < blockEnd)
}
}

View File

@@ -1,6 +1,6 @@
export const INIT_PASSWD = Buffer.from('NBDMAGIC') // "NBDMAGIC" ensure we're connected to a nbd server
export const OPTS_MAGIC = Buffer.from('IHAVEOPT') // "IHAVEOPT" start an option block
export const NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negotiation
export const NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negociation
export const NBD_OPT_EXPORT_NAME = 1
export const NBD_OPT_ABORT = 2
export const NBD_OPT_LIST = 3

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert'
import { Socket } from 'node:net'
import { connect } from 'node:tls'
import { pRetry, pDelay, pTimeout, pFromCallback } from 'promise-toolbox'
import { fromCallback, pRetry, pDelay, pTimeout, pFromCallback } from 'promise-toolbox'
import { readChunkStrict } from '@vates/read-chunk'
import { createLogger } from '@xen-orchestra/log'
import {
@@ -20,8 +20,6 @@ import {
OPTS_MAGIC,
NBD_CMD_DISC,
} from './constants.mjs'
import { spawn } from 'node:child_process'
const { warn } = createLogger('vates:nbd-client')
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
@@ -44,9 +42,9 @@ export default class NbdClient {
// AFAIK, there is no guaranty the server answers in the same order as the queries
// so we handle a backlog of command waiting for response and handle concurrency manually
#waitingForResponse // there is already a listener waiting for a response
#waitingForResponse // there is already a listenner waiting for a response
#nextCommandQueryId = BigInt(0)
#commandQueryBacklog // map of command waiting for a response queryId => { size/*in byte*/, resolve, reject}
#commandQueryBacklog // map of command waiting for an response queryId => { size/*in byte*/, resolve, reject}
#connected = false
#reconnectingPromise
@@ -197,7 +195,7 @@ export default class NbdClient {
}
// send export name we want to access.
// it's implicitly closing the negotiation phase.
// it's implictly closing the negociation phase.
await this.#write(OPTS_MAGIC)
await this.#writeInt32(NBD_OPT_EXPORT_NAME)
const exportNameBuffer = Buffer.from(this.#exportName)
@@ -220,22 +218,8 @@ export default class NbdClient {
}
#write(buffer) {
let timeout
const messageTimeout = this.#messageTimeout
const socket = this.#serverSocket
return Promise.race([
new Promise((resolve, reject) => {
timeout = setTimeout(() => {
reject(new Error('timeout'))
}, messageTimeout)
}),
new Promise(resolve =>
socket.write(buffer, () => {
clearTimeout(timeout)
resolve()
})
),
])
const promise = fromCallback.call(this.#serverSocket, 'write', buffer)
return pTimeout.call(promise, this.#messageTimeout)
}
async #readInt32() {
@@ -353,56 +337,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}`))
}
})
})
}
}

View File

@@ -30,8 +30,6 @@ export default class MultiNbdClient {
* open nbdConcurrency connections to NBD servers
* it must obtain at least one connection to succeed
* it tries to spread connections on multiple host
*
* @returns {Promise<void>}
*/
async connect() {
const candidates = [...this.#settings]
@@ -70,9 +68,7 @@ export default class MultiNbdClient {
await _connect()
}
if (this.#clients.length === 0) {
const error = new Error(`Fail to connect to any Nbd client`, { nbdInfos: this.#settings })
error.code = 'NO_NBD_AVAILABLE'
throw error
throw new Error(`Fail to connect to any Nbd client`)
}
if (this.#clients.length < this.#nbdConcurrency) {
warn(
@@ -81,30 +77,17 @@ export default class MultiNbdClient {
}
}
/**
* @returns {Promise<void>}
*/
async disconnect() {
await asyncEach(this.#clients, client => client.disconnect(), {
stopOnError: false,
})
}
/**
*
* @param {number} index
* @param {number} size
* @returns {Promise<Buffer>}
*/
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
const clientId = index % this.#clients.length
return this.#clients[clientId].readBlock(index, size)
}
/**
*
* @param {AsyncGenerator<Buffer>} indexGenerator
*/
async *readBlocks(indexGenerator) {
// default : read all blocks
const readAhead = []
@@ -129,17 +112,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)
}
}

View File

@@ -13,19 +13,18 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "3.4.0",
"version": "3.1.2",
"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.2.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.7.7"
"xen-api": "^4.7.1"
},
"devDependencies": {
"tap": "^18.7.0",

View File

@@ -24,7 +24,7 @@ $ npm install node-vsphere-soap --save
## Sample Code
### To connect to a vCenter server
### To connect to a vCenter server:
var nvs = require('node-vsphere-soap');
var vc = new nvs.Client(host, user, password, sslVerify);
@@ -54,7 +54,7 @@ $ npm install node-vsphere-soap --save
- userName - username of authenticated user
- fullName - full name of authenticated user
### To run a command
### To run a command:
var vcCmd = vc.runCommand( commandToRun, arguments );
vcCmd.once('result', function( result, raw, soapHeader) {

View File

@@ -7,7 +7,7 @@
This file creates the Client class
- when the class is instantiated, a connection will be made to the ESXi/vCenter server to verify that the creds are good
- upon a bad login, the connection will be terminated
- upon a bad login, the connnection will be terminated
*/

View File

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

View File

@@ -1,6 +1,6 @@
### Usual workflow
> This section presents how this library should be used to implement a classic two factor authentication.
> This section presents how this library should be used to implement a classic two factor authentification.
#### Setup

View File

@@ -18,7 +18,7 @@ npm install --save @vates/otp
### Usual workflow
> This section presents how this library should be used to implement a classic two factor authentication.
> This section presents how this library should be used to implement a classic two factor authentification.
#### Setup

View File

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

View File

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

View File

@@ -63,16 +63,16 @@ task.failure(error)
Inside a task:
```js
// the abort signal of the current task if any; otherwise, is `undefined`
// the abort signal of the current task if any, otherwise is `undefined`
Task.abortSignal
// sends an info on the current task if any; otherwise, does nothing
// sends an info on the current task if any, otherwise does nothing
Task.info(message, data)
// sends an info on the current task if any; otherwise, does nothing
// sends an info on the current task if any, otherwise does nothing
Task.warning(message, data)
// attaches a property to the current task if any; otherwise, does nothing
// attaches a property to the current task if any, otherwise does nothing
//
// the latest value takes precedence
//
@@ -149,7 +149,7 @@ const onProgress = makeOnProgress({
// This function is called each time a root task or a subtask is updated.
//
// `taskLog.$root` can be used to unconditionally access the root task.
// `taskLog.$root` can be used to uncondionally access the root task.
onTaskUpdate(taskLog) {},
})

View File

@@ -79,16 +79,16 @@ task.failure(error)
Inside a task:
```js
// the abort signal of the current task if any; otherwise, is `undefined`
// the abort signal of the current task if any, otherwise is `undefined`
Task.abortSignal
// sends an info on the current task if any; otherwise, does nothing
// sends an info on the current task if any, otherwise does nothing
Task.info(message, data)
// sends an info on the current task if any; otherwise, does nothing
// sends an info on the current task if any, otherwise does nothing
Task.warning(message, data)
// attaches a property to the current task if any; otherwise, does nothing
// attaches a property to the current task if any, otherwise does nothing
//
// the latest value takes precedence
//
@@ -165,7 +165,7 @@ const onProgress = makeOnProgress({
// This function is called each time a root task or a subtask is updated.
//
// `taskLog.$root` can be used to unconditionally access the root task.
// `taskLog.$root` can be used to uncondionally access the root task.
onTaskUpdate(taskLog) {},
})

View File

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

View File

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

View File

@@ -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,38 +28,8 @@ 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 () {
describe('contructor', function () {
it('data properties are passed to the start event', async function () {
const properties = { foo: 0, bar: 1 }
const task = createTask({ properties })
@@ -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',
})
})
@@ -295,7 +265,7 @@ describe('Task', function () {
assert.throws(() => task.set(name, value), { message: /^task has not started yet\b/ })
})
it(`emits a property message`, async function () {
it(`emits an property message`, async function () {
const task = createTask()
await task.run(async () => {
await Task.run(() => {
@@ -369,7 +339,7 @@ describe('Task', function () {
Task.warning('foo')
})
it('emits a warning message when run inside a task', async function () {
it('emits an warning message when run inside a task', async function () {
const task = createTask()
await task.run(() => {
Task.warning('foo')
@@ -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',
})
})

View File

@@ -13,7 +13,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.7.0",
"version": "0.6.1",
"engines": {
"node": ">=14"
},

View File

@@ -2,20 +2,7 @@
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)
'@typescript-eslint/no-empty-object-type': 'off',
},
})

View File

@@ -1,7 +1,7 @@
{
"name": "@vates/types",
"private": false,
"version": "1.23.0",
"version": "1.2.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",
@@ -18,14 +17,6 @@
"./lib/vates/*": {
"default": "./dist/lib/vates-*.mjs",
"type": "./dist/lib/vates-*.d.mts"
},
"./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",

View File

@@ -1,5 +1,3 @@
import type { XoHost } from './xo.mjs'
declare const __brand: unique symbol
export type Branded<TBrand extends string, TType = string> = TType & { [__brand]: TBrand }
@@ -651,18 +649,6 @@ export const OPAQUE_REF = { EMPTY: 'OpaqueRef:NULL' } as const
export type OPAQUE_REF_NULL = (typeof OPAQUE_REF)['EMPTY']
export const BACKUP_TYPE = { backup: 'backup', metadata: 'metadataBackup', mirror: 'mirrorBackup' } as const
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> = {
@@ -670,73 +656,47 @@ type XapiStatsResponse<T> = {
interval: number
stats: T
}
type StatValues = (number | null)[]
type RecordStatValues = Record<string, StatValues>
export type XapiStatsGranularity = 'seconds' | 'minutes' | 'hours' | 'days'
export type XapiHostStatsRaw = {
cpus?: RecordStatValues
ioThroughput?: {
r: RecordStatValues
w: RecordStatValues
export type XapiHostStats = XapiStatsResponse<{
cpus: Record<string, number[]>
ioThroughput: {
r: Record<string, number[]>
w: Record<string, number[]>
}
iops?: {
r: RecordStatValues
w: RecordStatValues
iops: {
r: Record<string, number[]>
w: Record<string, number[]>
}
iowait?: RecordStatValues
latency?: {
r: RecordStatValues
w: RecordStatValues
iowait: Record<string, number[]>
latency: {
r: Record<string, number[]>
w: Record<string, number[]>
}
load?: StatValues
memory?: StatValues
memoryFree?: StatValues
pifs?: {
rx: RecordStatValues
tx: RecordStatValues
load: number[]
memory: number[]
memoryFree: number[]
pifs: {
rx: Record<string, number[]>
tx: Record<string, number[]>
}
}
export type XapiHostStats = XapiStatsResponse<XapiHostStatsRaw>
}>
export type XapiVmStatsRaw = {
cpus?: RecordStatValues
cpuUsage?: StatValues
runstateFullrun?: StatValues
runstateFullContention?: StatValues
runstatePartialRun?: StatValues
runstatePartialContention?: StatValues
runstateConcurrencyHazard?: StatValues
runstateBlocked?: StatValues
iops?: {
r: RecordStatValues
w: RecordStatValues
export type XapiVmStats = XapiStatsResponse<{
cpus: Record<string, number[]>
iops: {
r: Record<string, number[]>
w: Record<string, number[]>
}
memory?: StatValues
memoryFree?: StatValues
memoryTarget?: StatValues
vifs?: {
rx: RecordStatValues
tx: RecordStatValues
memory: number[]
memoryFree?: number[]
vifs: {
rx: Record<string, number[]>
tx: Record<string, number[]>
}
vifErrors?: {
rx: RecordStatValues
tx: RecordStatValues
xvds: {
w: Record<string, number[]>
r: Record<string, number[]>
}
xvds?: {
w?: RecordStatValues
r?: RecordStatValues
total?: RecordStatValues
}
vbdLatency?: {
w: RecordStatValues
r: RecordStatValues
}
vbdIowait?: RecordStatValues
vbdInflight?: RecordStatValues
vbdAvgquSz?: RecordStatValues
}
export type XapiVmStats = XapiStatsResponse<XapiVmStatsRaw>
export type XapiPoolStats = Record<XoHost['id'], XapiHostStats | { error: Record<string, unknown> }>
}>

View File

@@ -1,6 +1,3 @@
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/complex-matcher.mjs'

View File

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

View File

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

View File

@@ -1,341 +0,0 @@
import {
WrappedXenApiRecord,
XenApiHost,
XenApiHostWrapped,
XenApiNetwork,
XenApiNetworkWrapped,
XenApiRecord,
XenApiSr,
XenApiTask,
XenApiVbd,
XenApiVdi,
XenApiVif,
XenApiVm,
XenApiVmWrapped,
XenApiVtpm,
} from '../xen-api.mjs'
import type {
BOND_MODE,
OPAQUE_REF_NULL,
SUPPORTED_VDI_FORMAT,
VBD_MODE,
VBD_TYPE,
VDI_TYPE,
VIF_LOCKING_MODE,
} from '../common.mjs'
import type { PassThrough, Readable } from 'node:stream'
import type {
XoGpuGroup,
XoHost,
XoNetwork,
XoPif,
XoSr,
XoUser,
XoVdi,
XoVgpuType,
XoVm,
XoVmTemplate,
XoVif,
} from '../xo.mjs'
export type XcpPatches = {
changelog?: {
author: string
date: number
description: string
}
description: string
license: string
name: string
release: string
size: number
url: string
version: string
}
export type XsPatches = {
conflicts?: string[]
date: string
description: string
documentationUrl?: string
guidances: string
name: string
id?: string
paid?: boolean
requirements?: string[]
upgrade?: boolean
url?: string
uuid?: string
}
export interface Xapi {
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'] }>>
getField<T extends XenApiRecord, K extends keyof T>(
type: Extract<WrappedXenApiRecord, T>['$type'],
ref: T['$ref'],
field: K
): Promise<T[K]>
connectVif(vifId: XoVif['id']): Promise<void>
createNetwork(
params:
| {
name: string
description?: string
mtu?: number
}
| {
name: string
description?: string
pifId: XoPif['id']
mtu?: number
/* between 0 and 4094 */
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>
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>
listMissingPatches(host: XoHost['id']): Promise<XcpPatches[] | XsPatches[]>
pool_emergencyShutdown(): Promise<void>
resumeVm(id: XoVm['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>
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>
startVm(
id: XoVm['id'],
opts?: {
bypassMacAddressesCheck?: boolean
force?: boolean
hostId?: XoHost['id']
/**
* if startOnly is true and the VM is not halted, throw VM_BAD_POWER_STATE
* otherwise, unpause/resume the VM
*
* @default false
*/
startOnly?: boolean
}
): Promise<void>
VM_export(
vmRef: XenApiVm['$ref'],
opts?: { cancelToken?: unknown; compress?: boolean; useSnapshot?: boolean }
): ReturnType<Xapi['getResource']>
VM_import(
stream: Readable,
srRef?: XenApiSr['$ref'],
onVmCreation?: null | ((vm: XenApiVm) => unknown)
): Promise<XenApiVm['$ref']>
createVm(
templateUuid: XoVmTemplate['uuid'],
metadataVm: {
affinityHost?: XoHost['id']
autoPoweron?: boolean
cpus?: number
memory?: number
name_description?: string
name_label: string
nameLabel?: string
clone?: boolean
installRepository?: XoVdi['id'] | '' | null
vdis?: (
| /** Create VDI */ {
name_label: string
size: number
sr?: XoSr['id']
name_description?: string
}
| /** Update VDI*/ {
userdevice: string
name_label?: string
size?: number
sr?: XoSr['id']
name_description?: string
}
| /** Destroy VDI*/ { destroy: true; userdevice: string }
)[]
vifs?: (
| /** Create/update VIF */ {
device?: string
ipv4_allowed?: string[]
ipv6_allowed?: string[]
mac?: string
mtu?: number
network: string
}
| /** Destroy VIF */ { destroy: true; device: string }
)[]
existingVdis?: {
$SR: XoSr['id']
size: number
userdevice: string
}[]
vgpuType?: XoVgpuType['id']
gpuGroup?: XoGpuGroup['id']
copyHostBiosStrings?: boolean
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,
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,
opts?: { networkConfig?: string }
): Promise<XoVdi['uuid']>
VM_destroy(
vmRef: XenApiVm['$ref'],
opts?: {
deleteDisks?: boolean
force?: boolean
bypassBlockedOperation?: boolean
forceDeleteDefaultTemplate?: boolean
}
): Promise<void>
getResource(
pathname: string,
params?: { host?: XenApiHost; query?: Record<string, unknown>; task?: boolean | XenApiTask['$ref'] }
): Promise<{ body: Readable }>
clearHost(host: Pick<XenApiHostWrapped, '$ref' | '$pool'>, force?: boolean): Promise<void>
disableHost(hostId: XoHost['id']): Promise<void>
enableHost(hostId: XoHost['id']): Promise<void>
getRecordByUuid<
Type extends WrappedXenApiRecord['$type'],
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']>
}

View File

@@ -64,17 +64,11 @@ import type {
VTPM_OPERATIONS,
VUSB_OPERATIONS,
} from './common.mjs'
import type { Xapi } from './lib/xen-orchestra-xapi.mjs'
// types automatically generated by the @ByScripts script.
// 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,15 +76,11 @@ type TagCallMethods = {
type WrapperXenApi<T, Type extends string, Fn = { (): void }> = T & {
$call: Fn
$callAsync: Fn
$pool: XenApiPool
$type: Type
$snapshot(params: {
cancelToken?: unknown
ignoredVdisTag?: string
name_label?: string
unplugVusbs?: boolean
}): Promise<XenApiVm['$ref']>
$xapi: Xapi
$xapi: {
call: <ReturnType>(...args: unknown[]) => Promise<ReturnType>
callAsync: <ReturnType>(...args: unknown[]) => Promise<ReturnType>
}
}
export interface XenApiSession {
@@ -168,8 +158,6 @@ export interface XenApiEvent {
timestamp?: string
}
type XenApiPoolCallMethods = TagCallMethods & {}
export interface XenApiPool {
$ref: Branded<'pool'>
allowed_operations: POOL_ALLOWED_OPERATIONS[]
@@ -232,7 +220,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 {
@@ -264,16 +252,8 @@ 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>
(method: 'clean_reboot'): Promise<void>
(method: 'hard_reboot'): Promise<void>
(method: 'pause'): Promise<void>
(method: 'suspend'): Promise<void>
(method: 'record_data_source', dataSource: string): Promise<void>
(method: 'forget_data_source_archives', dataSource: string): Promise<void>
}
export interface XenApiVm {
$ref: Branded<'VM'>
@@ -491,7 +471,6 @@ export interface XenApiDrTask {
uuid: string
}
type XenApiHostCallMethods = TagCallMethods & {}
export interface XenApiHost {
$ref: Branded<'host'>
address: string
@@ -568,7 +547,7 @@ export interface XenApiHost {
uuid: string
virtual_hardware_platform_versions: number[]
}
export type XenApiHostWrapped = WrapperXenApi<XenApiHost, 'host', XenApiHostCallMethods>
export type XenApiHostWrapped = WrapperXenApi<XenApiHost, 'host'>
export interface XenApiHostCrashdump {
$ref: Branded<'host_crashdump'>
@@ -623,10 +602,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[]
@@ -646,7 +621,7 @@ export interface XenApiNetwork {
uuid: string
VIFs: XenApiVif['$ref'][]
}
export interface XenApiNetworkWrapped extends WrapperXenApi<XenApiNetwork, 'network', XenApiNetworkCallMethods> {}
export interface XenApiVif {
$ref: Branded<'VIF'>
allowed_operations: VIF_OPERATIONS[]
@@ -730,7 +705,6 @@ export interface XenApiPif {
VLAN_slave_of: XenApiVlan['$ref'][]
VLAN: number
}
export type XenApiPifWrapped = WrapperXenApi<XenApiPif, 'PIF'>
export interface XenApiPifMetrics {
$ref: Branded<'PIF_metrics'>
@@ -786,15 +760,12 @@ export interface XenApiSm {
other_config: Record<string, string>
required_api_version: string
required_cluster_stack: string[]
supported_image_formats?: string[]
type: string
uuid: string
vendor: string
version: string
}
export type XenApiSmWrapped = WrapperXenApi<XenApiSm, 'SM'>
type XenApiSrCallMethods = TagCallMethods & {}
export interface XenApiSr {
$ref: Branded<'SR'>
allowed_operations: STORAGE_OPERATIONS[]
@@ -819,7 +790,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'>
@@ -845,8 +816,6 @@ export interface XenApiLvhd {
uuid: string
}
type XenApiVdiCallMethods = TagCallMethods & {}
export interface XenApiVdi {
$ref: Branded<'VDI'>
allow_caching: boolean
@@ -883,7 +852,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'>
@@ -908,7 +877,7 @@ export interface XenApiVbd {
unpluggable: boolean
userdevice: string
uuid: string
VDI: XenApiVdi['$ref'] | OPAQUE_REF_NULL
VDI: XenApiVdi['$ref']
VM: XenApiVm['$ref']
}
export type XenApiVbdWrapped = WrapperXenApi<XenApiVbd, 'VBD'>
@@ -936,7 +905,6 @@ export interface XenApiPbd {
SR: XenApiSr['$ref']
uuid: string
}
export type XenApiPbdWrapped = WrapperXenApi<XenApiPbd, 'PBD'>
/** @deprecated */
export interface XenApiCrashdump {
@@ -1011,7 +979,6 @@ export interface XenApiMessage {
timestamp: string
uuid: string
}
export type XenApiMessageWrapped = WrapperXenApi<XenApiMessage, 'message'>
export interface XenApiSecret {
$ref: Branded<'secret'>
@@ -1053,7 +1020,6 @@ export interface XenApiPci {
uuid: string
vendor_name: string
}
export type XenApiPciWrapped = WrapperXenApi<XenApiPci, 'PCI'>
export interface XenApiPgpu {
$ref: Branded<'PGPU'>
@@ -1070,7 +1036,6 @@ export interface XenApiPgpu {
supported_VGPU_types: XenApiVgpuType['$ref'][]
uuid: string
}
export type XenApiPgpuWrapped = WrapperXenApi<XenApiPgpu, 'PGPU'>
export interface XenApiGpuGroup {
$ref: Branded<'GPU_group'>
@@ -1085,7 +1050,6 @@ export interface XenApiGpuGroup {
uuid: string
VGPUs: XenApiVgpu['$ref'][]
}
export type XenApiGpuGroupWrapped = WrapperXenApi<XenApiGpuGroup, 'gpuGroup'>
export interface XenApiVgpu {
$ref: Branded<'VGPU'>
@@ -1123,7 +1087,6 @@ export interface XenApiVgpuType {
vendor_name: string
VGPUs: XenApiVgpu['$ref'][]
}
export type XenApiVgpuTypeWrapped = WrapperXenApi<XenApiVgpuType, 'vgpuType'>
export interface XenApiPvsSite {
$ref: Branded<'PVS_site'>
@@ -1373,9 +1336,6 @@ export type XenApiRecord =
export type WrappedXenApiRecord =
| XenApiHostWrapped
| XenApiNetworkWrapped
| XenApiPbdWrapped
| XenApiPifWrapped
| XenApiPoolWrapped
| XenApiSrWrapped
| XenApiVbdWrapped
@@ -1384,7 +1344,3 @@ export type WrappedXenApiRecord =
| XenApiVifWrapped
| XenApiVmWrapped
| XenApiVtpmWrapped
| XenApiPciWrapped
| XenApiGpuGroupWrapped
| XenApiPgpuWrapped
| XenApiVgpuTypeWrapped

View File

@@ -1,257 +0,0 @@
import { EventEmitter } from 'node:stream'
import type {
AnyXoBackupJob,
AnyXoJob,
AnyXoLog,
XapiXoRecord,
XoAuthenticationToken,
XoBackupRepository,
XoConfigBackupArchive,
XoGroup,
XoHost,
XoPool,
XoPoolBackupArchive,
XoProxy,
XoSchedule,
XoServer,
XoTask,
XoUser,
XoVm,
XoVmBackupArchive,
} from './xo.mjs'
import { VatesTask } from './lib/vates-task.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,
} from './index.mjs'
export type XapiConnection = Xapi & {
status: string
pool?: { uuid: string }
sessionId: string
_url?: { protocol: string; hostname: string; port?: string }
}
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
}
export type XoApp = {
config: {
get<T = string>(path: string): T
getOptional(path: string): Record<string, string> | 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 ------------
addUserToGroup: (userId: XoUser['id'], groupId: XoGroup['id']) => Promise<void>
authenticateUser: (
credentials: { token?: string; username?: string; password?: string },
userData?: { ip?: string },
opts?: { bypassOtp?: boolean; bypassTaskCreation?: boolean }
) => Promise<{ bypassOtp: boolean; expiration: number; user: XoUser }>
/* Throw if no authorization */
checkFeatureAuthorization(featureCode: string): Promise<void>
/* connect a server (XCP-ng/XenServer) */
connectXenServer(id: XoServer['id']): Promise<void>
createAuthenticationToken(opts: {
client?: {
id?: string
[key: string]: unknown
}
description?: string
expiresIn?: string | number
userId: XoUser['id']
}): Promise<XoAuthenticationToken>
createUser(params: { name?: string; password?: string; [key: string]: unknown }): Promise<XoUser>
deleteGroup(id: XoGroup['id']): Promise<void>
deleteUser(id: XoUser['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>
getAllGroups(): Promise<XoGroup[]>
getAllProxies(): Promise<XoProxy[]>
getAllJobs<T extends AnyXoBackupJob['type']>(type: T): Promise<Extract<AnyXoBackupJob, { type: T }>[]>
getAllJobs(type?: string): Promise<AnyXoJob[]>
getProxy(id: XoProxy['id']): Promise<XoProxy>
getRemote(id: XoBackupRepository['id']): Promise<XoBackupRepository>
getAllRemotes(): Promise<XoBackupRepository[]>
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[]>>>
/** 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 }): Promise<void>
rollingPoolUpdate(pool: XoPool, opts?: { rebootVm?: boolean; parentTask?: VatesTask }): Promise<void>
removeUserFromGroup(userId: XoUser['id'], id: XoGroup['id']): Promise<void>
runJob(job: AnyXoJob, schedule: XoSchedule): void
runWithApiContext: (user: XoUser | undefined, fn: () => void) => Promise<unknown>
/** 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>
updateGroup(
id: XoGroup['id'],
updates: {
name?: string
}
): void
getAllXapis(): Record<string, XapiConnection>
getObjects(opts?: { filter?: Record<string, unknown>; limit?: number }): Record<string, XapiXoRecord>
}

View File

@@ -5,12 +5,8 @@ import type {
DOMAIN_TYPE,
HOST_ALLOWED_OPERATIONS,
HOST_POWER_STATE,
IP_CONFIGURATION_MODE,
IPV6_CONFIGURATION_MODE,
NETWORK_OPERATIONS,
PGPU_DOM0_ACCESS,
POOL_ALLOWED_OPERATIONS,
PRIMARY_ADDRESS_TYPE,
STORAGE_OPERATIONS,
VDI_OPERATIONS,
VDI_TYPE,
@@ -18,8 +14,6 @@ import type {
VM_OPERATIONS,
VM_POWER_STATE,
} from './common.mjs'
import type * as CMType from './lib/complex-matcher.mjs'
import type { XenApiHost, XenApiPool } from './xen-api.mjs'
type BaseXapiXo = {
$pool: XoPool['id']
@@ -76,7 +70,7 @@ type BaseXoVm = BaseXapiXo & {
isNestedVirtEnabled: boolean
hasVendorDevice: boolean
high_availability: string
installTime?: number | null
installTime?: number
isFirmwareSupported: boolean
memory: {
dynamic: number[]
@@ -93,7 +87,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
@@ -102,14 +96,10 @@ type BaseXoVm = BaseXapiXo & {
secureBoot: boolean
snapshots: XoVmSnapshot['id'][]
startDelay: number
startTime?: number | null
startTime?: number
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
@@ -128,7 +118,7 @@ type BaseXoVm = BaseXapiXo & {
export type XoAlarm = Omit<XoMessage, '$object' | 'body'> & {
body: {
value?: string
value: string
name: string
}
object: {
@@ -138,83 +128,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
error?: Record<string, unknown>
id: Branded<'backup-repository'>
name: string
options?: string
proxy?: XoProxy['id']
url: string
}
export type XoGpuGroup = BaseXapiXo & {
id: Branded<'gpu-group'>
type: 'gpuGroup'
}
export type XoGroup = {
id: Branded<'group'>
name: string
@@ -239,7 +152,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
@@ -248,7 +161,7 @@ export type XoHost = BaseXapiXo & {
chipset_info: {
iommu?: boolean
}
controlDomain?: XoVmController['id']
controlDomain?: XoVm['id']
cpus: {
cores?: number
sockets?: number
@@ -343,82 +256,23 @@ 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'
}
export type XoPci = BaseXapiXo & {
$host?: XoHost['id']
class_name: string
device_name: string
id: Branded<'PCI'>
pci_id: string
type: 'PCI'
}
export type XoPgpu = BaseXapiXo & {
$host: XoHost['id']
$vgpus: XoVgpu['id'][]
dom0Access: PGPU_DOM0_ACCESS
enabledVgpuTypes: XoVgpuType['id'][]
gpuGroup?: XoGpuGroup['id']
host: XoHost['id']
id: Branded<'PGPU'>
isSystemDisplayDevice: boolean
pci?: XoPci['id']
// it seems that supportedVgpuMaxCapcities always return undefined (See: xapi-objet-to-xo)
/**
* @deprecated
*/
supportedVgpuMaxCapcities?: never
supportedVgpuTypes: XoVgpuType['id'][]
type: 'PGPU'
vgpus: XoVgpu['id'][]
}
export type XoPif = BaseXapiXo & {
$host: XoHost['id']
$network: XoNetwork['id']
attached: boolean
bondMaster?: XoPif['id']
bondSlaves?: XoPif['id'][]
carrier: boolean
device: string
deviceName?: string
disallowUnplug: boolean
dns: string
gateway: string
id: Branded<'PIF'>
ip: string
ipv6: string[]
ipv6Mode: IPV6_CONFIGURATION_MODE
isBondMaster: boolean
isBondSlave: boolean
mac: string
management: boolean
mode: IP_CONFIGURATION_MODE
mtu: number
netmask: string
physical: boolean
primaryAddressType: PRIMARY_ADDRESS_TYPE
speed?: number
type: 'PIF'
vlan: number
}
export type XoPool = BaseXapiXo & {
@@ -429,7 +283,7 @@ export type XoPool = BaseXapiXo & {
}
crashDumpSr?: XoSr['id']
current_operations: Record<string, POOL_ALLOWED_OPERATIONS>
default_SR?: XoSr['id']
defaultSr?: XoSr['id']
HA_enabled: boolean
haSrs: XoSr['id'][]
id: Branded<'pool'>
@@ -443,132 +297,19 @@ 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 = {
export type XoJob = {
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 & {
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
}
}
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
}
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
}
}
export type XoJob = BaseXoJob & {
type: 'call'
}
export type XoSchedule = {
cron: string
enabled: boolean
enable: boolean
id: Branded<'schedule'>
jobId: AnyXoJob['id']
jobId: XoJob['id']
name?: string
timezone?: string
}
@@ -581,7 +322,6 @@ export type XoServer = {
httpProxy?: string
id: Branded<'server'>
label?: string
master?: XoHost['id']
poolId?: XoPool['id']
poolNameDescription?: string
poolNameLabel?: string
@@ -595,9 +335,9 @@ export type XoSr = BaseXapiXo & {
$container: XoPool['id'] | XoHost['id']
VDIs: AnyXoVdi['id'][]
VDIs: XoVdi['id'][]
allocationStrategy?: 'thin' | 'thick' | 'unknown'
allocationStrategy: 'thin' | 'thick' | 'unknown'
content_type: string
current_operations: Record<string, STORAGE_OPERATIONS>
id: Branded<'SR'>
@@ -605,9 +345,9 @@ export type XoSr = BaseXapiXo & {
name_description: string
name_label: string
other_config: Record<string, string>
physical_usage: number
physical_usage: number | null
shared: boolean
size: number
size: number | null
sm_config: Record<string, string>
SR_type: string
tags: string[]
@@ -615,61 +355,6 @@ export type XoSr = BaseXapiXo & {
usage: number
}
export type XoSm = BaseXapiXo & {
id: Branded<'SM'>
SM_type: string
vendor: string
name_label: string
name_description: string
configuration: Record<string, string>
features: Record<string, number>
driver_filename: string
required_cluster_stack: string[]
supported_image_formats: string[]
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'
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
@@ -681,19 +366,6 @@ export type XoUser = {
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
@@ -703,8 +375,8 @@ export type XoVbd = BaseXapiXo & {
position: string
read_only: boolean
type: 'VBD'
VDI?: XoVdi['id'] | XoVdiSnapshot['id']
VM: AnyXoVm['id']
VDI: XoVdi['id']
VM: XoVm['id']
}
type BaseXoVdi = BaseXapiXo & {
@@ -720,7 +392,6 @@ type BaseXoVdi = BaseXapiXo & {
name_label: string
other_config: Record<string, string>
parent?: XoVdiUnmanaged['id']
image_format?: string
size: number
snapshots: XoVdiSnapshot['id'][]
tags: string[]
@@ -729,7 +400,6 @@ type BaseXoVdi = BaseXapiXo & {
export type XoVdi = BaseXoVdi & {
id: Branded<'VDI'>
chainPhysicalUsage: number
type: 'VDI'
}
@@ -740,24 +410,14 @@ export type XoVdiSnapshot = BaseXoVdi & {
type: 'VDI-snapshot'
}
export type XoVdiUnmanaged = Omit<BaseXoVdi, '$VBDs'> & {
export type XoVdiUnmanaged = BaseXoVdi & {
id: Branded<'VDI-unmanaged'>
type: 'VDI-unmanaged'
/**
* Unmanaged VDI have no VBDs
*/
$VBDs: never[]
}
export type XoVgpu = BaseXapiXo & {
id: Branded<'VGPU'>
type: 'vgpu'
}
export type XoVgpuType = BaseXapiXo & {
id: Branded<'vgpu-type'>
type: 'vgpuType'
type: 'VGPU'
}
export type XoVif = BaseXapiXo & {
@@ -785,7 +445,6 @@ export type XoVif = BaseXapiXo & {
export type XoVm = BaseXoVm & {
id: Branded<'VM'>
type: 'VM'
vulnerabilities: { xsa468: boolean | { reason: string; driver?: string; version?: string } }
}
export type XoVmController = BaseXoVm & {
@@ -826,14 +485,9 @@ export type XoVtpm = BaseXapiXo & {
export type XapiXoRecord =
| XoAlarm
| XoGpuGroup
| XoHost
| XoMessage
| XoNetwork
| XoPbd
| XoPci
| XoPgpu
| XoPif
| XoPool
| XoSr
| XoVbd
@@ -841,40 +495,13 @@ export type XapiXoRecord =
| XoVdiSnapshot
| XoVdiUnmanaged
| XoVgpu
| XoVgpuType
| XoVif
| XoVm
| XoVmController
| XoVmSnapshot
| XoVmTemplate
| XoVtpm
| XoSm
export type NonXapiXoRecord =
| AnyXoBackupArchive
| AnyXoJob
| AnyXoLog
| XoGroup
| XoProxy
| XoGroup
| XoProxy
| XoJob
| XoBackupRepository
| XoSchedule
| XoServer
| XoTask
| XoUser
export type NonXapiXoRecord = XoGroup | XoJob | XoSchedule | XoServer | XoUser
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

View File

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

View File

@@ -20,7 +20,7 @@ function main([inputPath = 0]) {
try {
data = JSON.parse(input)
} catch (error) {
// fall back to XML
// fallback to XML
log(parseXml(input))
return
}

View File

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

View File

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

View File

@@ -4,10 +4,6 @@ Usage:
xo-backups clean-vms [--merge] [--remove] xo-vm-backups/*
alternatively you can target the full remote, thus handling S3/Azure or encrypted remotes
xo-backups clean-vms [--merge] [--remove] --remote=REMOTEURL
Detects and repair issues with VM backups.
Options:

View File

@@ -20,10 +20,6 @@ Usage:
xo-backups clean-vms [--merge] [--remove] xo-vm-backups/*
alternatively you can target the full remote, thus handling S3/Azure or encrypted remotes
xo-backups clean-vms [--merge] [--remove] --remote=REMOTEURL
Detects and repair issues with VM backups.
Options:

View File

@@ -1,81 +1,13 @@
import { asyncEach } from '@vates/async-each'
import { asyncMap } from '@xen-orchestra/async-map'
import { RemoteAdapter } from '@xen-orchestra/backups/RemoteAdapter.mjs'
import { getSyncedHandler } from '@xen-orchestra/fs'
import getopts from 'getopts'
import { basename, dirname, join } from 'path'
import { basename, dirname } from 'path'
import Disposable from 'promise-toolbox/Disposable'
import { pathToFileURL } from 'url'
async function cleanFiles(vmDirs, { fix, merge, remove }) {
let nbSucceed = 0
let nbFailed = 0
let nbStarted = 0
const total = vmDirs.length
// don't assume all the files uses the same remote
await asyncEach(
vmDirs,
vmDir =>
Disposable.use(getSyncedHandler({ url: pathToFileURL(dirname(vmDir)).href }), async handler => {
try {
nbStarted++
await new RemoteAdapter(handler).cleanVm(basename(vmDir), {
fixMetadata: fix,
remove,
merge,
logInfo: (...args) => console.log(...args),
logWarn: (...args) => console.warn(...args),
})
nbSucceed++
} catch (error) {
nbFailed++
console.error('adapter.cleanVm', vmDir, error, { nbStarted, nbSucceed, nbFailed, total })
}
}),
{ concurrency: 2 }
)
console.log('done', { nbSucceed, nbFailed })
}
async function cleanRemote(url, { fix, merge, remove }) {
let nbSucceed = 0
let nbFailed = 0
let nbStarted = 0
await Disposable.use(getSyncedHandler({ url }), async handler => {
const remoteAdapter = new RemoteAdapter(handler)
const vmUuids = await remoteAdapter.listAllVms()
const total = vmUuids.length
await asyncEach(
vmUuids,
async vmUuid => {
try {
nbStarted++
await remoteAdapter.cleanVm(join('xo-vm-backups', vmUuid), {
fixMetadata: fix,
remove,
merge,
logInfo: (...args) => console.log(...args),
logWarn: (...args) => console.warn(...args),
})
nbSucceed++
} catch (error) {
nbFailed++
console.error('adapter.cleanVm', vmUuid, error, { nbStarted, nbSucceed, nbFailed, total })
}
},
{ concurrency: 2 }
)
})
console.log('done', { nbSucceed, nbFailed })
}
export default async function cleanVms(args) {
const {
_: vmDirs,
fix,
remote,
remove,
merge,
} = getopts(args, {
const { _, fix, remove, merge } = getopts(args, {
alias: {
fix: 'f',
remove: 'r',
@@ -88,9 +20,19 @@ export default async function cleanVms(args) {
},
})
if (remote) {
return cleanRemote(remote, { fix, merge, remove })
} else {
return cleanFiles(vmDirs, { fix, merge, remove })
}
await asyncMap(_, vmDir =>
Disposable.use(getSyncedHandler({ url: pathToFileURL(dirname(vmDir)).href }), async handler => {
try {
await new RemoteAdapter(handler).cleanVm(basename(vmDir), {
fixMetadata: fix,
remove,
merge,
logInfo: (...args) => console.log(...args),
logWarn: (...args) => console.warn(...args),
})
} catch (error) {
console.error('adapter.cleanVm', vmDir, error)
}
})
)
}

View File

@@ -6,13 +6,12 @@
"preferGlobal": true,
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@vates/async-each": "^1.0.3",
"@xen-orchestra/async-map": "^0.1.3",
"@xen-orchestra/backups": "^0.72.0",
"@xen-orchestra/fs": "^4.8.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.59.0",
"@xen-orchestra/fs": "^4.5.0",
"filenamify": "^6.0.0",
"getopts": "^2.2.5",
"lodash": "^4.18.0",
"lodash": "^4.17.15",
"promise-toolbox": "^0.21.0"
},
"engines": {
@@ -28,7 +27,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "1.1.11",
"version": "1.0.30",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -1,4 +1,4 @@
import { Task } from '@vates/task'
import { Task } from './Task.mjs'
export class HealthCheckVmBackup {
#restoredVm
@@ -14,7 +14,7 @@ export class HealthCheckVmBackup {
async run() {
return Task.run(
{
properties: { name: 'vmstart' },
name: 'vmstart',
},
async () => {
let restoredVm = this.#restoredVm

View File

@@ -2,16 +2,14 @@ import assert from 'node:assert'
import { formatFilenameDate } from './_filenameDate.mjs'
import { importIncrementalVm } from './_incrementalVm.mjs'
import { Task } from './Task.mjs'
import { watchStreamSize } from './_watchStreamSize.mjs'
import { VhdNegative, VhdSynthetic } from 'vhd-lib'
import { decorateClass } from '@vates/decorate-with'
import { Task } from '@vates/task'
import { createLogger } from '@xen-orchestra/log'
import { dirname, join } from 'node:path'
import pickBy from 'lodash/pickBy.js'
import { defer } from 'golike-defer'
import { NegativeDisk } from '@xen-orchestra/disk-transform'
import { openDiskChain } from './disks/openDiskChain.mjs'
import { resetVmOtherConfig } from './_otherConfig.mjs'
const { debug, info, warn } = createLogger('xo:backups:importVmBackup')
async function resolveUuid(xapi, cache, uuid, type) {
@@ -30,10 +28,10 @@ export class ImportVmBackup {
metadata,
srUuid,
xapi,
settings: { additionalVmTag, newMacAddresses, mapVdisSrs = {}, useDifferentialRestore = false } = {},
settings: { additionnalVmTag, newMacAddresses, mapVdisSrs = {}, useDifferentialRestore = false } = {},
}) {
this._adapter = adapter
this._importIncrementalVmSettings = { additionalVmTag, newMacAddresses, mapVdisSrs, useDifferentialRestore }
this._importIncrementalVmSettings = { additionnalVmTag, newMacAddresses, mapVdisSrs, useDifferentialRestore }
this._metadata = metadata
this._srUuid = srUuid
this._xapi = xapi
@@ -61,12 +59,12 @@ export class ImportVmBackup {
const metadata = this._metadata
const { mapVdisSrs } = this._importIncrementalVmSettings
const { vbds, vhds, vifs, vm, vmSnapshot, vtpms } = metadata
const disks = {}
const metadataDir = dirname(metadata._filename)
const streams = {}
const metdataDir = dirname(metadata._filename)
const vdis = ignoredVdis === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !ignoredVdis.has(vdi.uuid))
for (const [vdiRef, vdi] of Object.entries(vdis)) {
const vhdPath = join(metadataDir, vhds[vdiRef])
const vhdPath = join(metdataDir, vhds[vdiRef])
let xapiDisk
try {
@@ -78,7 +76,7 @@ export class ImportVmBackup {
let snapshotCandidate, backupCandidate
if (xapiDisk !== undefined) {
debug('found disks, will search its snapshots', { snapshots: xapiDisk.snapshots })
debug('found disks, wlll search its snapshots', { snapshots: xapiDisk.snapshots })
for (const snapshotRef of xapiDisk.snapshots) {
const snapshot = await this._xapi.getRecord('VDI', snapshotRef)
@@ -97,7 +95,7 @@ export class ImportVmBackup {
// have a corresponding backup more recent than metadata ?
const pathToSnapshotData = await this.#getPathOfVdiSnapshot(snapshot.uuid)
if (pathToSnapshotData === undefined) {
debug('no backup linked to this snapshot')
debug('no backup linked to this snaphot')
continue
}
if (snapshot.$SR.uuid !== (mapVdisSrs[vdi.$snapshot_of$uuid] ?? this._srUuid)) {
@@ -112,21 +110,20 @@ export class ImportVmBackup {
}
}
let disk
const backupWithSnapshotPath = join(metadataDir, backupCandidate ?? '')
let stream
const backupWithSnapshotPath = join(metdataDir, backupCandidate ?? '')
if (vhdPath === backupWithSnapshotPath) {
// all the data are already on the host
debug('direct reuse of a snapshot')
disk = null
stream = null
vdis[vdiRef].baseVdi = snapshotCandidate
// go next disk , we won't use this stream
continue
}
const parent = await openDiskChain({
handler: this._adapter._handler,
path: vhdPath,
})
let disposableDescendants
const disposableSynthetic = await VhdSynthetic.fromVhdChain(this._adapter._handler, vhdPath)
// this will also clean if another disk of this VM backup fails
// if user really only need to restore non failing disks he can retry with ignoredVdis
@@ -135,7 +132,8 @@ export class ImportVmBackup {
if (!disposed) {
disposed = true
try {
await parent?.close()
await disposableDescendants?.dispose()
await disposableSynthetic?.dispose()
} catch (error) {
warn('openVhd: failed to dispose VHDs', { error })
}
@@ -143,10 +141,11 @@ export class ImportVmBackup {
}
$defer.onFailure(() => disposeOnce())
debug('got vhd synthetic of parents', parent)
const parentVhd = disposableSynthetic.value
await parentVhd.readBlockAllocationTable()
debug('got vhd synthetic of parents', parentVhd.length)
if (snapshotCandidate !== undefined) {
let descendant, negativeDisk
try {
debug('will try to use differential restore', {
backupWithSnapshotPath,
@@ -154,37 +153,39 @@ export class ImportVmBackup {
vdiRef,
})
descendant = await openDiskChain({
handler: this._adapter._handler,
path: backupWithSnapshotPath,
disposableDescendants = await VhdSynthetic.fromVhdChain(this._adapter._handler, backupWithSnapshotPath, {
until: vhdPath,
})
const descendantsVhd = disposableDescendants.value
await descendantsVhd.readBlockAllocationTable()
debug('got vhd synthetic of descendants')
negativeDisk = new NegativeDisk(parent, descendant)
const negativeVhd = new VhdNegative(parentVhd, descendantsVhd)
debug('got vhd negative')
// update the stream with the negative vhd stream
disk = negativeDisk
stream = await negativeVhd.stream()
vdis[vdiRef].baseVdi = snapshotCandidate
} catch (error) {
// can be a broken VHD chain, a vhd chain with a key backup, ....
// not an irrecuperable error, don't dispose parentVhd, and fall back to full restore
// not an irrecuperable error, don't dispose parentVhd, and fallback to full restore
warn(`can't use differential restore`, { error })
descendant?.close()
negativeDisk?.close()
disposableDescendants?.dispose()
}
}
// didn't make a negative stream : fall back to classic stream
if (disk === undefined) {
// didn't make a negative stream : fallback to classic stream
if (stream === undefined) {
debug('use legacy restore')
disk = parent
stream = await parentVhd.stream()
}
info('everything is ready, will transfer', disk)
disks[vdiRef] = disk
stream.on('end', disposeOnce)
stream.on('close', disposeOnce)
stream.on('error', disposeOnce)
info('everything is ready, will transfer', stream.length)
streams[`${vdiRef}.vhd`] = stream
}
return {
disks,
streams,
vbds,
vdis,
version: '1.0.0',
@@ -195,7 +196,7 @@ export class ImportVmBackup {
}
async #decorateIncrementalVmMetadata() {
const { additionalVmTag, mapVdisSrs, useDifferentialRestore } = this._importIncrementalVmSettings
const { additionnalVmTag, mapVdisSrs, useDifferentialRestore } = this._importIncrementalVmSettings
const ignoredVdis = new Set(
Object.entries(mapVdisSrs)
@@ -212,8 +213,8 @@ export class ImportVmBackup {
const cache = new Map()
const mapVdisSrRefs = {}
if (additionalVmTag !== undefined) {
backup.vm.tags.push(additionalVmTag)
if (additionnalVmTag !== undefined) {
backup.vm.tags.push(additionnalVmTag)
}
for (const [vdiUuid, srUuid] of Object.entries(mapVdisSrs)) {
mapVdisSrRefs[vdiUuid] = await resolveUuid(xapi, cache, srUuid, 'SR')
@@ -240,11 +241,12 @@ export class ImportVmBackup {
assert.strictEqual(metadata.mode, 'delta')
backup = await this.#decorateIncrementalVmMetadata()
Object.values(backup.streams).forEach(stream => watchStreamSize(stream, sizeContainer))
}
return Task.run(
{
properties: { name: 'transfer' },
name: 'transfer',
},
async () => {
const xapi = this._xapi
@@ -255,14 +257,6 @@ export class ImportVmBackup {
: await importIncrementalVm(backup, await xapi.getRecord('SR', srRef), {
newMacAddresses,
})
let size = 0
if (isFull) {
size = sizeContainer.size
} else {
for (const disk of Object.values(backup.disks)) {
size += disk.getNbGeneratedBlock() * disk.getBlockSize()
}
}
const remoteName = adapter._handler._remote.name
let desc = `Restored on ${formatFilenameDate(+new Date())}`
if (remoteName !== undefined) {
@@ -279,11 +273,10 @@ export class ImportVmBackup {
`${metadata.vm.name_label} (${formatFilenameDate(metadata.timestamp)})`
),
xapi.call('VM.set_name_description', vmRef, desc),
resetVmOtherConfig(xapi, vmRef),
])
return {
size,
size: sizeContainer.size,
id: await xapi.getField('VM', vmRef, 'uuid'),
}
}

View File

@@ -2,7 +2,7 @@ import { asyncEach } from '@vates/async-each'
import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
import { compose } from '@vates/compose'
import { createLogger } from '@xen-orchestra/log'
import { VhdDirectory, VhdSynthetic } from 'vhd-lib'
import { createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, VhdSynthetic } from 'vhd-lib'
import { decorateMethodsWith } from '@vates/decorate-with'
import { deduped } from '@vates/disposable/deduped.js'
import { dirname, join, resolve } from 'node:path'
@@ -10,6 +10,7 @@ import { execFile } from 'child_process'
import { mount } from '@vates/fuse-vhd'
import { readdir, lstat } from 'node:fs/promises'
import { synchronized } from 'decorator-synchronized'
import { v4 as uuidv4 } from 'uuid'
import { ZipFile } from 'yazl'
import Disposable from 'promise-toolbox/Disposable'
import fromCallback from 'promise-toolbox/fromCallback'
@@ -18,7 +19,7 @@ import groupBy from 'lodash/groupBy.js'
import pDefer from 'promise-toolbox/defer'
import pickBy from 'lodash/pickBy.js'
import reduce from 'lodash/reduce.js'
import * as tar from 'tar'
import tar from 'tar'
import zlib from 'zlib'
import { BACKUP_DIR } from './_getVmBackupDir.mjs'
@@ -27,24 +28,19 @@ import { formatFilenameDate } from './_filenameDate.mjs'
import { getTmpDir } from './_getTmpDir.mjs'
import { isMetadataFile } from './_backupType.mjs'
import { isValidXva } from './_isValidXva.mjs'
import { listPartitions, LVM_PARTITION_TYPE_MBR, LVM_PARTITION_TYPE_GPT } from './_listPartitions.mjs'
import { listPartitions, LVM_PARTITION_TYPE } from './_listPartitions.mjs'
import { lvs, pvs } from './_lvm.mjs'
import { watchStreamSize } from './_watchStreamSize.mjs'
import { RemoteVhdDisk } from './disks/RemoteVhdDisk.mjs'
import { openDiskChain } from './disks/openDiskChain.mjs'
import { toVhdStream, writeToVhdDirectory } from 'vhd-lib/disk-consumer/index.mjs'
import { ReadAhead } from '@xen-orchestra/disk-transform'
export const DIR_XO_CONFIG_BACKUPS = 'xo-config-backups'
export const DIR_XO_POOL_METADATA_BACKUPS = 'xo-pool-metadata-backups'
const IMMUTABILITY_METADATA_FILENAME = '/immutability.json'
const IMMUTABILTY_METADATA_FILENAME = '/immutability.json'
const { debug, warn } = createLogger('xo:backups:RemoteAdapter')
export const compareTimestamp = (a, b) => a.timestamp - b.timestamp
const compareTimestamp = (a, b) => a.timestamp - b.timestamp
const noop = Function.prototype
@@ -205,7 +201,7 @@ export class RemoteAdapter {
})
}
// check if we will be allowed to merge a vhd created in this adapter
// check if we will be allowed to merge a a vhd created in this adapter
// with the vhd at path `path`
async isMergeableParent(packedParentUid, path) {
return await Disposable.use(VhdSynthetic.fromVhdChain(this.handler, path), vhd => {
@@ -312,8 +308,8 @@ export class RemoteAdapter {
}
async deleteVmBackups(files) {
const metadata = await asyncMap(files, file => this.readVmBackupMetadata(file))
const { delta, full, ...others } = groupBy(metadata, 'mode')
const metadatas = await asyncMap(files, file => this.readVmBackupMetadata(file))
const { delta, full, ...others } = groupBy(metadatas, 'mode')
const unsupportedModes = Object.keys(others)
if (unsupportedModes.length !== 0) {
@@ -498,7 +494,7 @@ export class RemoteAdapter {
const results = []
await asyncMapSettled(partitions, partition =>
partition.type === LVM_PARTITION_TYPE_MBR || partition.type === LVM_PARTITION_TYPE_GPT
partition.type === LVM_PARTITION_TYPE
? this._listLvmLogicalVolumes(devicePath, partition, results)
: results.push(partition)
)
@@ -575,7 +571,7 @@ export class RemoteAdapter {
}
}
async #getCacheableDataListVmBackups(dir) {
async #getCachabledDataListVmBackups(dir) {
debug('generating cache', { path: dir })
const handler = this._handler
@@ -613,11 +609,6 @@ export class RemoteAdapter {
// if cache is missing or broken => regenerate it and return
async _readCacheListVmBackups(vmUuid) {
// immutable remote can't use any caching
// since the cache file may be non modifiable
if (this._handler.isImmutable()) {
return this.#getCacheableDataListVmBackups(`${BACKUP_DIR}/${vmUuid}`)
}
const path = this.#getVmBackupsCache(vmUuid)
const cache = await this._readCache(path)
@@ -627,7 +618,7 @@ export class RemoteAdapter {
}
// nothing cached, or cache unreadable => regenerate it
const backups = await this.#getCacheableDataListVmBackups(`${BACKUP_DIR}/${vmUuid}`)
const backups = await this.#getCachabledDataListVmBackups(`${BACKUP_DIR}/${vmUuid}`)
if (backups === undefined) {
return
}
@@ -698,24 +689,22 @@ export class RemoteAdapter {
return path
}
async writeVhd(path, disk, { validator = noop, writeBlockConcurrency } = {}) {
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency } = {}) {
const handler = this._handler
if (this.useVhdDirectory()) {
await writeToVhdDirectory({
disk,
target: {
handler,
path,
concurrency: writeBlockConcurrency,
validator,
compression: 'brotli',
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
const size = await createVhdDirectoryFromStream(handler, dataPath, input, {
concurrency: writeBlockConcurrency,
compression: this.#getCompressionType(),
async validator() {
await input.task
return validator.apply(this, arguments)
},
})
await VhdAbstract.createAlias(handler, path, dataPath)
return size
} else {
const stream = await toVhdStream(disk)
await this.outputStream(path, stream, { validator, checksum: false })
await validator(path)
return this.outputStream(path, input, { checksum, validator })
}
}
@@ -739,16 +728,30 @@ export class RemoteAdapter {
}
// open the hierarchy of ancestors until we find a full one
async _createVhdDisk(handler, path, { useChain }) {
let disk
if (useChain) {
disk = await openDiskChain({ handler, path })
} else {
disk = new RemoteVhdDisk({ handler, path })
await disk.init()
async _createVhdStream(handler, path, { useChain }) {
const disposableSynthetic = useChain ? await VhdSynthetic.fromVhdChain(handler, path) : await openVhd(handler, path)
// I don't want the vhds to be disposed on return
// but only when the stream is done ( or failed )
let disposed = false
const disposeOnce = async () => {
if (!disposed) {
disposed = true
try {
await disposableSynthetic.dispose()
} catch (error) {
warn('openVhd: failed to dispose VHDs', { error })
}
}
}
disk = new ReadAhead(disk)
return disk
const synthetic = disposableSynthetic.value
await synthetic.readBlockAllocationTable()
const stream = await synthetic.stream()
stream.on('end', disposeOnce)
stream.on('close', disposeOnce)
stream.on('error', disposeOnce)
return stream
}
async readIncrementalVmBackup(metadata, ignoredVdis, { useChain = true } = {}) {
@@ -756,14 +759,14 @@ export class RemoteAdapter {
const { vbds, vhds, vifs, vm, vmSnapshot, vtpms } = metadata
const dir = dirname(metadata._filename)
const vdis = ignoredVdis === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !ignoredVdis.has(vdi.uuid))
const disks = {}
const streams = {}
await asyncMapSettled(Object.keys(vdis), async ref => {
delete vdis[ref].baseVdi
disks[ref] = await this._createVhdDisk(handler, join(dir, vhds[ref]), { useChain })
streams[`${ref}.vhd`] = await this._createVhdStream(handler, join(dir, vhds[ref]), { useChain })
})
return {
disks,
streams,
vbds,
vdis,
version: '1.0.0',
@@ -781,10 +784,10 @@ export class RemoteAdapter {
let json
let isImmutable = false
let remoteIsImmutable = false
// if the remote is immutable, check if this metadata is also immutable
// if the remote is immutable, check if this metadatas are also immutables
try {
// this file is not encrypted
await this._handler._readFile(IMMUTABILITY_METADATA_FILENAME)
await this._handler._readFile(IMMUTABILTY_METADATA_FILENAME)
remoteIsImmutable = true
} catch (error) {
if (error.code !== 'ENOENT') {
@@ -797,7 +800,7 @@ export class RemoteAdapter {
json = await this.handler.readFile(path, { flag: 'r+' })
// s3 handler don't respect flags
} catch (err) {
// retry without triggering immutability check ,only on immutable remote
// retry without triggerring immutbaility check ,only on immutable remote
if (err.code === 'EPERM' && remoteIsImmutable) {
isImmutable = true
json = await this._handler.readFile(path, { flag: 'r' })

View File

@@ -24,7 +24,7 @@ export class RestoreMetadataBackup {
const dataFileName = resolve('/', backupId, metadata.data ?? 'data.json').slice(1)
const data = await handler.readFile(dataFileName)
// if data is JSON, sent it as a plain string; otherwise, consider the data as binary and encode it
// if data is JSON, sent it as a plain string, otherwise, consider the data as binary and encode it
const isJson = dataFileName.endsWith('.json')
return isJson ? data.toString() : { encoding: 'base64', data: data.toString('base64') }
}

View File

@@ -0,0 +1,155 @@
import CancelToken from 'promise-toolbox/CancelToken'
import Zone from 'node-zone'
const logAfterEnd = log => {
const error = new Error('task has already ended')
error.log = log
throw error
}
const noop = Function.prototype
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 =>
error instanceof Error
? {
...error, // Copy enumerable properties.
code: error.code,
errors: serializeErrors(error.errors), // supports AggregateError
message: error.message,
name: error.name,
stack: error.stack,
}
: error
const $$task = Symbol('@xen-orchestra/backups/Task')
export class Task {
static get cancelToken() {
const task = Zone.current.data[$$task]
return task !== undefined ? task.#cancelToken : CancelToken.none
}
static run(opts, fn) {
return new this(opts).run(fn, true)
}
static wrapFn(opts, fn) {
// compatibility with @decorateWith
if (typeof fn !== 'function') {
;[fn, opts] = [opts, fn]
}
return function () {
return Task.run(typeof opts === 'function' ? opts.apply(this, arguments) : opts, () => fn.apply(this, arguments))
}
}
#cancelToken
#id = Math.random().toString(36).slice(2)
#onLog
#zone
constructor({ name, data, onLog }) {
let parentCancelToken, parentId
if (onLog === undefined) {
const parent = Zone.current.data[$$task]
if (parent === undefined) {
onLog = noop
} else {
onLog = log => parent.#onLog(log)
parentCancelToken = parent.#cancelToken
parentId = parent.#id
}
}
const zone = Zone.current.fork('@xen-orchestra/backups/Task')
zone.data[$$task] = this
this.#zone = zone
const { cancel, token } = CancelToken.source(parentCancelToken && [parentCancelToken])
this.#cancelToken = token
this.cancel = cancel
this.#onLog = onLog
this.#log('start', {
data,
message: name,
parentId,
})
}
failure(error) {
this.#end('failure', serializeError(error))
}
info(message, data) {
this.#log('info', { data, message })
}
/**
* Run a function in the context of this task
*
* In case of error, the task will be failed.
*
* @typedef Result
* @param {() => Result} fn
* @param {boolean} last - Whether the task should succeed if there is no error
* @returns Result
*/
run(fn, last = false) {
return this.#zone.run(() => {
try {
const result = fn()
let then
if (result != null && typeof (then = result.then) === 'function') {
then.call(result, last && (value => this.success(value)), error => this.failure(error))
} else if (last) {
this.success(result)
}
return result
} catch (error) {
this.failure(error)
throw error
}
})
}
success(value) {
this.#end('success', value)
}
warning(message, data) {
this.#log('warning', { data, message })
}
wrapFn(fn, last) {
const task = this
return function () {
return task.run(() => fn.apply(this, arguments), last)
}
}
#end(status, result) {
this.#log('end', { result, status })
this.#onLog = logAfterEnd
}
#log(event, props) {
this.#onLog({
...props,
event,
taskId: this.#id,
timestamp: Date.now(),
})
}
}
for (const method of ['info', 'warning']) {
Task[method] = (...args) => Zone.current.data[$$task]?.[method](...args)
}

View File

@@ -1,5 +1,4 @@
export const isMetadataFile = filename => filename.endsWith('.json')
export const isVhdFile = filename => filename.endsWith('.vhd')
export const isVhdSumFile = filename => filename.endsWith('.vhd.checksum')
export const isXvaFile = filename => filename.endsWith('.xva')
export const isXvaSumFile = filename => filename.endsWith('.xva.checksum')

View File

@@ -14,10 +14,10 @@ import { decorateMethodsWith } from '@vates/decorate-with'
import { deduped } from '@vates/disposable/deduped.js'
import { getHandler } from '@xen-orchestra/fs'
import { parseDuration } from '@vates/parse-duration'
import { Task } from '@vates/task'
import { Xapi } from '@xen-orchestra/xapi'
import { RemoteAdapter } from './RemoteAdapter.mjs'
import { Task } from './Task.mjs'
createCachedLookup().patchGlobal()
@@ -178,8 +178,8 @@ process.on('message', async message => {
const result = message.runWithLogs
? await Task.run(
{
properties: { name: 'backup run', ...message.data.jobData },
onProgress: data =>
name: 'backup run',
onLog: data =>
emitMessage({
data,
type: 'log',

View File

@@ -83,12 +83,12 @@ test('It remove broken vhd', async () => {
await handler.writeFile(`${basePath}/notReallyAVhd.vhd`, 'I AM NOT A VHD')
assert.equal((await handler.list(basePath)).length, 1)
let logged = ''
let loggued = ''
const logInfo = message => {
logged += message
loggued += message
}
await adapter.cleanVm(rootPath, { remove: false, logInfo, logWarn: logInfo, lock: false })
assert.equal(logged, `VHD check error`)
assert.equal(loggued, `VHD check error`)
// not removed
assert.deepEqual(await handler.list(basePath), ['notReallyAVhd.vhd'])
// really remove it
@@ -98,7 +98,7 @@ test('It remove broken vhd', async () => {
test('it remove vhd with missing or multiple ancestors', async () => {
// one with a broken parent, should be deleted
await generateVhd(`${basePath}/abandoned.vhd`, {
await generateVhd(`${basePath}/abandonned.vhd`, {
header: {
parentUnicodeName: 'gone.vhd',
parentUuid: uniqueIdBuffer(),
@@ -118,21 +118,21 @@ test('it remove vhd with missing or multiple ancestors', async () => {
`metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [`${basePath}/child.vhd`, `${basePath}/abandoned.vhd`],
vhds: [`${basePath}/child.vhd`, `${basePath}/abandonned.vhd`],
}),
{ flags: 'w' }
)
// clean
let logged = ''
let loggued = ''
const logInfo = message => {
logged += message + '\n'
loggued += message + '\n'
}
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: logInfo, lock: false })
const deletedOrphanVhd = logged.match(/deleting orphan VHD/g) || []
const deletedOrphanVhd = loggued.match(/deleting orphan VHD/g) || []
assert.equal(deletedOrphanVhd.length, 1) // only one vhd should have been deleted
// we don't test the file on disk, since they will all be marker as unused and deleted without a metadata.json file
// we don't test the filew on disk, since they will all be marker as unused and deleted without a metadata.json file
})
test('it remove backup meta data referencing a missing vhd in delta backup', async () => {
@@ -144,12 +144,12 @@ test('it remove backup meta data referencing a missing vhd in delta backup', asy
vhds: [
`${relativePath}/orphan.vhd`,
`${relativePath}/child.vhd`,
// abandoned.json is not here
// abandonned.json is not here
],
})
)
await generateVhd(`${basePath}/abandoned.vhd`)
await generateVhd(`${basePath}/abandonned.vhd`)
// one orphan, which is a full vhd, no parent
const orphan = await generateVhd(`${basePath}/orphan.vhd`)
@@ -162,12 +162,12 @@ test('it remove backup meta data referencing a missing vhd in delta backup', asy
},
})
let logged = ''
let loggued = ''
const logInfo = message => {
logged += message + '\n'
loggued += message + '\n'
}
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: logInfo, lock: false })
let matched = logged.match(/deleting unused VHD/g) || []
let matched = loggued.match(/deleting unused VHD/g) || []
assert.equal(matched.length, 1) // only one vhd should have been deleted
// a missing vhd cause clean to remove all vhds
@@ -179,14 +179,14 @@ test('it remove backup meta data referencing a missing vhd in delta backup', asy
`deleted.vhd`, // in metadata but not in vhds
`orphan.vhd`,
`child.vhd`,
// abandoned.vhd is not here anymore
// abandonned.vhd is not here anymore
],
}),
{ flags: 'w' }
)
logged = ''
loggued = ''
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: () => {}, lock: false })
matched = logged.match(/deleting unused VHD/g) || []
matched = loggued.match(/deleting unused VHD/g) || []
assert.equal(matched.length, 2) // all vhds (orphan and child ) should have been deleted
})
@@ -212,7 +212,6 @@ test('it merges delta of non destroyed chain', async () => {
parentUnicodeName: 'orphan.vhd',
parentUuid: orphan.footer.uuid,
},
blocks: [0, 1],
})
// a grand child
await generateVhd(`${basePath}/grandchild.vhd`, {
@@ -222,22 +221,21 @@ test('it merges delta of non destroyed chain', async () => {
},
})
let logged = []
let loggued = []
const logInfo = message => {
logged.push(message)
loggued.push(message)
}
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: logInfo, lock: false })
assert.equal(logged[0], `unexpected number of entries in backup cache`)
assert.equal(loggued[0], `unexpected number of entries in backup cache`)
logged = []
const result = await adapter.cleanVm(rootPath, { remove: true, merge: true, logInfo, logWarn: () => {}, lock: false })
assert.ok(result.size > 0, `no merged size`)
const [merging] = logged
loggued = []
await adapter.cleanVm(rootPath, { remove: true, merge: true, logInfo, logWarn: () => {}, lock: false })
const [merging] = loggued
assert.equal(merging, `merging VHD chain`)
const metadata = JSON.parse(await handler.readFile(`${rootPath}/metadata.json`))
// size should be the size of children + grand children after the merge
assert.equal(metadata.size, 4299776)
assert.equal(metadata.size, 104960)
// merging is already tested in vhd-lib, don't retest it here (and theses vhd are as empty as my stomach at 12h12)
// only check deletion
@@ -288,56 +286,8 @@ test('it finish unterminated merge ', async () => {
assert.equal(remainingVhds.includes('child.vhd'), true)
})
test('it finishes unterminated merge when child is a disk chain', async () => {
// This test covers the case where a merge was interrupted and the child disk is a disk chain.
await handler.writeFile(
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
size: 209920,
vhds: [`${relativePath}/child.vhd`],
})
)
const ancestor = await generateVhd(`${basePath}/ancestor.vhd`)
const intermediate = await generateVhd(`${basePath}/intermediate.vhd`, {
header: {
parentUnicodeName: 'ancestor.vhd',
parentUuid: ancestor.footer.uuid,
},
})
await generateVhd(`${basePath}/child.vhd`, {
header: {
parentUnicodeName: 'intermediate.vhd',
parentUuid: intermediate.footer.uuid,
},
})
// merge state file with chain field
await handler.writeFile(
`${basePath}/.ancestor.vhd.merge.json`,
JSON.stringify({
parent: { uuid: '0' },
child: { uuid: '0' },
chain: ['ancestor.vhd', 'intermediate.vhd', 'child.vhd'],
currentBlock: 0,
mergedDataSize: 0,
step: 'mergeBlocks',
diskSize: 0,
})
)
await adapter.cleanVm(rootPath, { remove: true, merge: true, logWarn: () => {}, lock: false })
// ancestor should be renamed to child.vhd (replacing it), intermediate.vhd should be deleted
const remainingVhds = await handler.list(basePath)
assert.equal(remainingVhds.length, 1)
assert.equal(remainingVhds[0], 'child.vhd')
})
// each of the vhd can be a file, a directory, an alias to a file or an alias to a directory
// the message and resulting files should be identical to the output with vhd files which is tested independently
// the message an resulting files should be identical to the output with vhd files which is tested independantly
describe('tests multiple combination ', { concurrency: 1 }, () => {
for (const useAlias of [true, false]) {
for (const vhdMode of ['file', 'directory']) {
@@ -358,10 +308,10 @@ describe('tests multiple combination ', { concurrency: 1 }, () => {
await VhdAbstract.createAlias(handler, 'broken.alias.vhd', brokenVhdDataPath)
}
// a vhd non referenced in metadata
// a vhd non referenced in metada
await generateVhd(`${basePath}/nonreference.vhd`, { useAlias, mode: vhdMode })
// an abandoned delta vhd without its parent
await generateVhd(`${basePath}/abandoned.vhd`, {
// an abandonded delta vhd without its parent
await generateVhd(`${basePath}/abandonned.vhd`, {
useAlias,
mode: vhdMode,
header: {
@@ -452,12 +402,13 @@ describe('tests multiple combination ', { concurrency: 1 }, () => {
const metadata = JSON.parse(await handler.readFile(`${rootPath}/metadata.json`))
// size should be the size of children + grand children + clean after the merge
assert.deepEqual(metadata.size, 4404224)
assert.deepEqual(metadata.size, vhdMode === 'file' ? 6502400 : 6501888)
// broken vhd, non referenced, abandoned should be deleted ( alias and data)
// broken vhd, non referenced, abandonned should be deleted ( alias and data)
// ancestor and child should be merged
// grand child and clean vhd should not have changed
const survivors = await handler.list(basePath)
// console.log(survivors)
if (useAlias) {
const dataSurvivors = await handler.list(basePath + '/data')
// the goal of the alias : do not move a full folder
@@ -503,7 +454,7 @@ test('check Aliases should work alone', async () => {
logWarn: () => {},
})
// only ok have survived
// only ok have suvived
const alias = (await handler.list('vhds')).filter(f => f.endsWith('.vhd'))
assert.equal(alias.length, 1)

View File

@@ -1,20 +1,40 @@
import * as UUID from 'uuid'
import sum from 'lodash/sum.js'
import { asyncMap } from '@xen-orchestra/async-map'
import { Constants, openVhd, VhdAbstract } from 'vhd-lib'
import { Constants, openVhd, VhdAbstract, VhdFile } from 'vhd-lib'
import { isVhdAlias, resolveVhdAlias } from 'vhd-lib/aliases.js'
import { basename, dirname, resolve } from 'node:path'
import { isMetadataFile, isVhdFile, isVhdSumFile, isXvaFile, isXvaSumFile } from './_backupType.mjs'
import { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } from './_backupType.mjs'
import { limitConcurrency } from 'limit-concurrency-decorator'
import { RemoteVhdDisk } from './disks/RemoteVhdDisk.mjs'
import { RemoteVhdDiskChain } from './disks/RemoteVhdDiskChain.mjs'
import { MergeRemoteDisk } from './disks/MergeRemoteDisk.mjs'
import { mergeVhdChain } from 'vhd-lib/merge.js'
import { Task } from './Task.mjs'
import { Disposable } from 'promise-toolbox'
import { Task } from '@vates/task'
import handlerPath from '@xen-orchestra/fs/path'
const { DISK_TYPES } = Constants
// checking the size of a vhd directory is costly
// 1 Http Query per 1000 blocks
// we only check size of all the vhd are VhdFiles
function shouldComputeVhdsSize(handler, vhds) {
if (handler.isEncrypted) {
return false
}
return vhds.every(vhd => vhd instanceof VhdFile)
}
const computeVhdsSize = (handler, vhdPaths) =>
Disposable.use(
vhdPaths.map(vhdPath => openVhd(handler, vhdPath)),
async vhds => {
if (shouldComputeVhdsSize(handler, vhds)) {
const sizes = await asyncMap(vhds, vhd => vhd.getSize())
return sum(sizes)
}
}
)
// chain is [ ancestor, child_1, ..., child_n ]
async function _mergeVhdChain(handler, chain, { logInfo, remove, mergeBlockConcurrency }) {
logInfo(`merging VHD chain`, { chain })
@@ -31,15 +51,7 @@ async function _mergeVhdChain(handler, chain, { logInfo, remove, mergeBlockConcu
}
}, 10e3)
try {
const parentDisk = new RemoteVhdDisk({ handler, path: chain.shift() })
const childDisks = []
for (const path of chain) {
childDisks.push(new RemoteVhdDisk({ handler, path }))
}
const childDiskChain = new RemoteVhdDiskChain({ disks: childDisks })
const mergeRemoteDisk = new MergeRemoteDisk(handler, {
return await mergeVhdChain(handler, chain, {
logInfo,
mergeBlockConcurrency,
onProgress({ done: d, total: t }) {
@@ -48,14 +60,6 @@ async function _mergeVhdChain(handler, chain, { logInfo, remove, mergeBlockConcu
},
removeUnused: remove,
})
const isResumingMerge = await mergeRemoteDisk.isResuming(parentDisk)
await parentDisk.init({ force: isResumingMerge })
await childDiskChain.init({ force: isResumingMerge })
const result = await mergeRemoteDisk.merge(parentDisk, childDiskChain)
return result
} finally {
clearInterval(handle)
}
@@ -64,12 +68,10 @@ async function _mergeVhdChain(handler, chain, { logInfo, remove, mergeBlockConcu
const noop = Function.prototype
const INTERRUPTED_VHDS_REG = /^\.(.+)\.merge.json$/
const listVhds = async (handler, vmDir, logWarn) => {
const vhds = new Set()
const aliases = {}
const interruptedVhds = new Map()
const checksums = new Set()
await asyncMap(
await handler.list(`${vmDir}/vdis`, {
@@ -83,15 +85,11 @@ const listVhds = async (handler, vmDir, logWarn) => {
}),
async vdiDir => {
const list = await handler.list(vdiDir, {
filter: file => isVhdFile(file) || INTERRUPTED_VHDS_REG.test(file) || isVhdSumFile(file),
filter: file => isVhdFile(file) || INTERRUPTED_VHDS_REG.test(file),
})
aliases[vdiDir] = list.filter(vhd => isVhdAlias(vhd)).map(file => `${vdiDir}/${file}`)
await asyncMap(list, async file => {
if (isVhdSumFile(file)) {
checksums.add(`${vdiDir}/${file}`)
return
}
const res = INTERRUPTED_VHDS_REG.exec(file)
if (res === null) {
vhds.add(`${vdiDir}/${file}`)
@@ -113,7 +111,7 @@ const listVhds = async (handler, vmDir, logWarn) => {
)
)
return { vhds, interruptedVhds, aliases, checksums }
return { vhds, interruptedVhds, aliases }
}
export async function checkAliases(
@@ -230,16 +228,7 @@ export async function cleanVm(
const vhdParents = { __proto__: null }
const vhdChildren = { __proto__: null }
const { vhds, interruptedVhds, aliases, checksums } = await listVhds(handler, vmDir, logWarn)
// from 5.110 to 5.113 we computed checksum for vhd file
// but never used nor removed them
await asyncMap(checksums, async path => {
if (remove) {
logInfo('deleting checksum file ', { path })
return handler.unlink(path)
}
})
const { vhds, interruptedVhds, aliases } = await listVhds(handler, vmDir, logWarn)
// remove broken VHDs
await asyncMap(vhds, async path => {
@@ -514,27 +503,23 @@ export async function cleanVm(
})
}
let totalMergedDataSize = 0
const metadataWithMergedVhd = {}
const doMerge = async () => {
await asyncMap(toMerge, async chain => {
const { finalDiskSize, mergedDataSize } = await limitedMergeVhdChain(handler, chain, {
const { finalVhdSize } = await limitedMergeVhdChain(handler, chain, {
logInfo,
logWarn,
remove,
mergeBlockConcurrency,
})
totalMergedDataSize += mergedDataSize
const metadataPath = vhdsToJSons[chain[chain.length - 1]] // all the chain should have the same metadata file
metadataWithMergedVhd[metadataPath] = (metadataWithMergedVhd[metadataPath] ?? 0) + finalDiskSize
const metadataPath = vhdsToJSons[chain[chain.length - 1]] // all the chain should have the same metada file
metadataWithMergedVhd[metadataPath] = (metadataWithMergedVhd[metadataPath] ?? 0) + finalVhdSize
})
return { size: totalMergedDataSize }
}
await Promise.all([
...unusedVhdsDeletion,
toMerge.length !== 0 && (merge ? Task.run({ properties: { name: 'merge' } }, doMerge) : () => Promise.resolve()),
toMerge.length !== 0 && (merge ? Task.run({ name: 'merge' }, doMerge) : () => Promise.resolve()),
asyncMap(unusedXvas, path => {
logWarn('unused XVA', { path })
if (remove) {
@@ -562,7 +547,7 @@ export async function cleanVm(
let fileSystemSize
const mergedSize = metadataWithMergedVhd[metadataPath]
const { mode, size, xva } = metadata
const { mode, size, vhds, xva } = metadata
try {
if (mode === 'full') {
@@ -578,7 +563,21 @@ export async function cleanVm(
})
}
} catch (error) {
// will fail with encrypted remote
// can fail with encrypted remote
}
} else if (mode === 'delta') {
// don't warn if the size has changed after a merge
if (mergedSize === undefined) {
const linkedVhds = Object.keys(vhds).map(key => resolve('/', vmDir, vhds[key]))
fileSystemSize = await computeVhdsSize(handler, linkedVhds)
// the size is not computed in some cases (e.g. VhdDirectory)
if (fileSystemSize !== undefined && fileSystemSize !== size) {
logWarn('cleanVm: incorrect backup size in metadata', {
path: metadataPath,
actual: size ?? 'none',
expected: fileSystemSize,
})
}
}
}
} catch (error) {
@@ -596,7 +595,7 @@ export async function cleanVm(
// all disks are now key disk
metadata.isVhdDifferencing = {}
for (const id of Object.keys(metadata.vdis ?? {})) {
metadata.isVhdDifferencing[id] = false
metadata.isVhdDifferencing[`${id}.vhd`] = false
}
}
mustRegenerateCache = true
@@ -623,6 +622,5 @@ export async function cleanVm(
return {
// boolean whether some VHDs were merged (or should be merged)
merge: toMerge.length !== 0,
size: totalMergedDataSize,
}
}

View File

@@ -1,58 +1,5 @@
// @ts-check
import moment from 'moment-timezone'
import assert from 'node:assert'
/**
* @typedef {import('moment-timezone').Moment} Moment
*/
/**
* @typedef {Object} LTRSettings
* @property {number} [firstHourOfTheDay] - First hour of the day (for daily retention)
* @property {number} [firstDayOfWeek] - First day of the week (for weekly retention)
* @property {number} [firstDayOfMonth] - First day of the month (for monthly retention)
* @property {number} [firstDayOfYear] - First day of the year (for yearly retention)
*/
/**
* @typedef {Object} LTRConfig
* @property {number} retention - Number of time buckets to retain
* @property {LTRSettings} [settings] - Optional settings for the retention period
*/
/**
* @typedef {Object} LongTermRetention
* @property {LTRConfig} [daily] - Daily retention configuration
* @property {LTRConfig} [weekly] - Weekly retention configuration
* @property {LTRConfig} [monthly] - Monthly retention configuration
* @property {LTRConfig} [yearly] - Yearly retention configuration
*/
/**
* @typedef {Object} Entry
* @property {number | undefined} timestamp - Unix timestamp of the entry
* @property {string} id
* @property {*} [key] - Any other properties
*/
/**
* @typedef {Object} DateBucket
* @property {number} remaining - Number of remaining buckets to fill
* @property {string|null} lastMatchingBucket - Last bucket key that was matched
* @property {(date: Moment) => string} formatter - Function to format date into bucket key
* @property {Object.<string, Entry>} entries - Map of bucket keys to entries
*/
/**
* @typedef {Object} LTRDefinition
* @property {(options: {firstHourOfTheDay?: number, firstDayOfWeek?: number, firstDayOfMonth?: number, firstDayOfYear?: number, dateCreator: (date: Moment) => Moment}) => (date: Moment) => string} makeDateFormatter
* @property {string} [ancestor] - Name of the parent retention period
*/
/**
* Creates a function that converts dates to moment objects in a specific timezone
* @param {string|undefined} timezone - IANA timezone string (e.g., 'America/New_York')
* @returns {(date: Moment | number) => Moment} Function that creates timezoned moment objects
*/
function instantiateTimezonedDateCreator(timezone) {
return date => {
@@ -62,12 +9,9 @@ function instantiateTimezonedDateCreator(timezone) {
}
}
/**
* @type {Object.<string, LTRDefinition>}
*/
const LTR_DEFINITIONS = {
daily: {
makeDateFormatter: ({ firstHourOfTheDay = 0, dateCreator }) => {
makeDateFormatter: ({ firstHourOfTheDay = 0, dateCreator } = {}) => {
return date => {
const copy = dateCreator(date)
copy.hour(copy.hour() - firstHourOfTheDay)
@@ -76,20 +20,20 @@ const LTR_DEFINITIONS = {
},
},
weekly: {
makeDateFormatter: ({ firstDayOfWeek = 0 /* relative to timezone week start */, dateCreator }) => {
makeDateFormatter: ({ firstDayOfWeek = 0 /* relative to timezone week start */, dateCreator } = {}) => {
return date => {
const copy = dateCreator(date)
copy.date(copy.date() - firstDayOfWeek)
copy.date(date.date() - firstDayOfWeek)
// warning, the year in term of week may different from YYYY
// since the computation of the first week of a year is timezone dependent
// since the computation of the first week of a year is timezone dependant
return copy.format('gggg-ww')
}
},
ancestor: 'daily',
},
monthly: {
makeDateFormatter: ({ firstDayOfMonth = 0, dateCreator }) => {
makeDateFormatter: ({ firstDayOfMonth = 0, dateCreator } = {}) => {
return date => {
const copy = dateCreator(date)
copy.date(copy.date() - firstDayOfMonth)
@@ -99,7 +43,7 @@ const LTR_DEFINITIONS = {
ancestor: 'weekly',
},
yearly: {
makeDateFormatter: ({ firstDayOfYear = 0, dateCreator }) => {
makeDateFormatter: ({ firstDayOfYear = 0, dateCreator } = {}) => {
return date => {
const copy = dateCreator(date)
copy.date(copy.date() - firstDayOfYear)
@@ -109,23 +53,27 @@ const LTR_DEFINITIONS = {
ancestor: 'monthly',
},
}
/**
* Groups entries into date buckets based on long-term retention configuration
* @template {Entry} T
* @param {Array<T>} entries - Array of entries sorted in descending order by timestamp
* @param {LongTermRetention} longTermRetention - Configuration for retention periods
* @param {string | undefined} timezone - IANA timezone string
* @returns {Object.<string, DateBucket>} Map of retention period names to their date buckets
*/
export function getLtrEntries(entries, longTermRetention, timezone) {
/**
* @type {Object.<string, DateBucket>}
*/
// returns all entries but the last retention-th
/**
* return the entries too old to be kept
* if multiple entries are i the same time bucket : keep only the most recent one
* if an entry is valid in any of the bucket OR the minRetentionCount : keep it
* if a bucket is completly empty : it does not count as one, thus it may extend the retention
* @returns Array<Backup>
*/
export function getOldEntries(minRetentionCount, entries, { longTermRetention = {}, timezone } = {}) {
assert.strictEqual(
typeof minRetentionCount,
'number',
`minRetentionCount must be a number, got ${JSON.stringify(minRetentionCount)}`
)
assert.strictEqual(
minRetentionCount >= 0,
true,
`minRetentionCount must be a positive number, got ${JSON.stringify(minRetentionCount)}`
)
const dateBuckets = {}
if (!longTermRetention || Object.keys(longTermRetention).length === 0) {
return {}
}
const dateCreator = instantiateTimezonedDateCreator(timezone)
// only check buckets that have a retention set
for (const [duration, { retention, settings }] of Object.entries(longTermRetention)) {
@@ -133,7 +81,7 @@ export function getLtrEntries(entries, longTermRetention, timezone) {
assert.notStrictEqual(
timezone,
undefined,
`timezone must defined for ltr, got ${JSON.stringify({ longTermRetention, timezone })}`
`timezone must defined for ltr, got ${JSON.stringify({ minRetentionCount, longTermRetention, timezone })}`
)
assert.strictEqual(
typeof retention,
@@ -149,106 +97,40 @@ export function getLtrEntries(entries, longTermRetention, timezone) {
remaining: retention,
lastMatchingBucket: null,
formatter: LTR_DEFINITIONS[duration].makeDateFormatter({ ...settings, dateCreator }),
entries: {}, // bucket => entry id
}
}
const nb = entries.length
let previousTimestamp = -1
const oldEntries = []
for (let i = nb - 1; i >= 0; i--) {
const entry = entries[i]
// force it so the compiler is sure we have a timestamp
if (entry.timestamp === undefined) {
assert.notStrictEqual(
entry.timestamp,
undefined,
`Can't compute long term retention if entries don't have a timestamp`
)
continue
}
// we go through the entries from the last (most recent) to the first (oldest)
assert.ok(
previousTimestamp === -1 || entry.timestamp < previousTimestamp,
`entries must be sorted in desc order ${new Date(entry.timestamp)} , previous : > ${new Date(previousTimestamp)} `
)
previousTimestamp = entry.timestamp
const entryDate = dateCreator(entry.timestamp)
let shouldBeKept = false
for (const [duration, { remaining, lastMatchingBucket, formatter }] of Object.entries(dateBuckets)) {
if (remaining === 0) {
continue
}
const bucket = formatter(entryDate)
if (lastMatchingBucket !== bucket) {
if (remaining === 0) {
continue
if (lastMatchingBucket !== null) {
assert.strictEqual(
lastMatchingBucket > bucket,
true,
`entries must be sorted in asc order ${lastMatchingBucket} ${bucket}`
)
}
dateBuckets[duration].lastMatchingBucket = bucket
shouldBeKept = true
dateBuckets[duration].remaining -= 1
}
dateBuckets[duration].entries[bucket] = entry
}
}
return dateBuckets
}
/**
* return the status of an entry
* @param {Array<Entry>} entries
* @param {Entry} entry
* @param {LongTermRetention} longTermRetention - Configuration for retention periods
* @param {string | undefined} timezone - IANA timezone string
* @returns {Array<{duration: string, dateBucket:string}>}
*/
export function getEntryStatus(entries, entry, longTermRetention, timezone) {
const dateBuckets = getLtrEntries(entries, longTermRetention, timezone)
const entryBuckets = []
for (const [duration, { entries }] of Object.entries(dateBuckets)) {
for (const [dateBucket, bucketEntry] of Object.entries(entries)) {
if (entry.id === bucketEntry.id) {
entryBuckets.push({ duration, dateBucket })
dateBuckets[duration].lastMatchingBucket = bucket
}
}
}
return entryBuckets
}
/**
* return the entries too old to be kept
* if multiple entries are in the same time bucket: keep only the oldest one
* if an entry is valid in any of the bucket OR the minRetentionCount: keep it
* if a bucket is completely empty: it does not count as one, thus it may extend the retention
* if an entry is within the most recents minRetentionCount entries : keep it
*
* @param {number} minRetentionCount - Minimum number of most recent entries to always keep
* @param {Array<Entry>} entries - Array of entries sorted in descending order by timestamp
* @param {Object} options - Additional options
* @param {LongTermRetention} [options.longTermRetention={}] - Configuration for retention periods
* @param {string | undefined} [options.timezone] - IANA timezone string
* @returns {Array<Entry>} Array of entries that can be removed
*/
export function getOldEntries(minRetentionCount, entries, { longTermRetention = {}, timezone } = {}) {
assert.strictEqual(
typeof minRetentionCount,
'number',
`minRetentionCount must be a number, got ${JSON.stringify(minRetentionCount)}`
)
assert.ok(
minRetentionCount >= 0,
`minRetentionCount must be a positive number, got ${JSON.stringify(minRetentionCount)}`
)
const dateBuckets = getLtrEntries(entries, longTermRetention, timezone)
const kept = new Set(entries.filter((_, index) => index >= entries.length - minRetentionCount))
/**
* @type {Set<Entry>}
*/
for (const { entries } of Object.values(dateBuckets)) {
for (const entry of Object.values(entries)) {
kept.add(entry)
if (i >= nb - minRetentionCount) {
shouldBeKept = true
}
if (!shouldBeKept) {
oldEntries.push(entry)
}
}
// ensure order is the same as the source
const oldEntries = entries.filter((entry, index) => {
return !kept.has(entry)
})
return oldEntries
// we expect the entries to be in the right order
return oldEntries.reverse()
}

View File

@@ -10,28 +10,24 @@ describe('_getOldEntries() should succeed', () => {
1,
[
{ timestamp: 1, id: 1 },
{ timestamp: 2, id: 2 },
{ timestamp: 3, id: 3 },
{ timestamp: 3, id: 2 },
{ timestamp: 2, id: 3 },
],
],
expectedIds: [1, 2],
testLabel: 'should handle number based retention ',
},
{
args: [3, [{ id: 1 }, 4, new Date(), 'whatever']],
expectedIds: [1],
testLabel: 'should handle number based retention without timestamp ',
},
{
args: [
0,
[
{ timestamp: +new Date('2024-09-01 00:00:00'), id: 1 }, // too old
{ timestamp: +new Date('2024-09-01 00:01:00'), id: 2 }, // too old
{ timestamp: +new Date('2024-09-02 00:09:00'), id: 3 }, // oldest in same day
{ timestamp: +new Date('2024-09-01 00:01:00'), id: 1 }, // too old
{ timestamp: +new Date('2024-09-01 00:00:00'), id: 2 }, // too old
{ timestamp: +new Date('2024-09-02 00:09:00'), id: 3 }, // too old in same day
{ timestamp: +new Date('2024-09-02 00:10:00'), id: 4 },
{ timestamp: +new Date('2024-09-03 00:09:00'), id: 5 },
{ timestamp: +new Date('2024-09-04 00:09:00'), id: 6 }, // oldest in same day
{ timestamp: +new Date('2024-09-04 00:09:00'), id: 6 }, // too old in same day
{ timestamp: +new Date('2024-09-04 00:10:00'), id: 7 },
],
{
@@ -41,22 +37,22 @@ describe('_getOldEntries() should succeed', () => {
timezone: 'Europe/Paris',
},
],
expectedIds: [1, 2, 4, 7],
expectedIds: [1, 2, 3, 6],
testLabel: 'should handle day based retention ',
},
{
args: [
0,
[
{ timestamp: +new Date('2024-08-30 00:01:00'), id: 1 }, // week n-3 too old
{ timestamp: +new Date('2024-08-31 00:00:00'), id: 2 }, // week n-3 too old
{ timestamp: +new Date('2024-09-03 00:09:00'), id: 3 }, // week n-2, oldest
{ timestamp: +new Date('2024-09-01 00:01:00'), id: 1 }, // week n-3 too old
{ timestamp: +new Date('2024-09-02 00:00:00'), id: 2 }, // week n-3 too old
{ timestamp: +new Date('2024-09-03 00:09:00'), id: 3 }, // week n-2
{ timestamp: +new Date('2024-09-04 00:09:00'), id: 4 }, // week n-2
{ timestamp: +new Date('2024-09-05 00:09:00'), id: 5 }, // week n-2
{ timestamp: +new Date('2024-09-06 00:09:00'), id: 6 }, // week n-2
{ timestamp: +new Date('2024-09-07 00:09:00'), id: 7 }, // week n-2
{ timestamp: +new Date('2024-09-15 00:09:00'), id: 8 }, // week n-1, oldest
{ timestamp: +new Date('2024-09-21 00:09:00'), id: 9 }, // week n-1 most recent
{ timestamp: +new Date('2024-09-07 00:09:00'), id: 7 }, // week n-2, most recent kept
{ timestamp: +new Date('2024-09-09 00:09:00'), id: 8 }, // week n-1, too old
{ timestamp: +new Date('2024-09-15 00:09:00'), id: 9 }, // week n-1 kept
{ timestamp: +new Date('2024-09-22 00:09:00'), id: 10 }, // week n kept
],
{
@@ -66,7 +62,7 @@ describe('_getOldEntries() should succeed', () => {
timezone: 'Europe/Paris',
},
],
expectedIds: [1, 2, 4, 5, 6, 7, 9],
expectedIds: [1, 2, 3, 4, 5, 6, 8],
testLabel: 'should handle week based retention ',
},
{
@@ -89,26 +85,26 @@ describe('_getOldEntries() should succeed', () => {
timezone: 'Europe/Paris',
},
],
expectedIds: [1, 4, 5, 7, 8],
expectedIds: [1, 3, 4, 6, 7],
testLabel: 'should handle month based retention',
},
{
args: [
2,
0,
[
{ timestamp: +new Date('2023-05-18 00:09:00'), id: 1 }, //
{ timestamp: +new Date('2024-06-15 00:09:00'), id: 2 }, // year n -1
{ timestamp: +new Date('2024-07-04 00:09:00'), id: 3 }, //
{ timestamp: +new Date('2024-08-12 00:09:00'), id: 4 }, //
{ timestamp: +new Date('2024-09-05 00:09:00'), id: 5 }, //
{ timestamp: +new Date('2024-10-02 00:09:00'), id: 6 }, // month n-4
{ timestamp: +new Date('2024-11-01 00:09:00'), id: 7 }, // month n-3
{ timestamp: +new Date('2024-12-17 00:09:00'), id: 8 }, // week n-3
{ timestamp: +new Date('2024-12-24 00:09:00'), id: 9 }, // week n-2, month n-2,
{ timestamp: +new Date('2025-01-01 00:09:00'), id: 10 }, // week n-1, year n
{ timestamp: +new Date('2025-01-02 00:10:00'), id: 11 }, // day n-1, month n-1,
{ timestamp: +new Date('2025-12-31 00:09:00'), id: 12 }, // day n , week n , month n
{ timestamp: +new Date('2025-12-31 10:09:00'), id: 13 }, // min retention
{ timestamp: +new Date('2023-05-18 00:09:00'), id: 1 }, // too old
{ timestamp: +new Date('2024-06-15 00:09:00'), id: 2 }, // too old in same year
{ timestamp: +new Date('2024-07-04 00:09:00'), id: 3 }, // too old
{ timestamp: +new Date('2024-08-12 00:09:00'), id: 4 }, // too old
{ timestamp: +new Date('2024-09-05 00:09:00'), id: 5 }, // too old
{ timestamp: +new Date('2024-10-02 00:09:00'), id: 6 }, // new month,
{ timestamp: +new Date('2024-11-01 00:09:00'), id: 7 }, // new month , week reached retention
{ timestamp: +new Date('2024-12-17 00:09:00'), id: 8 }, // new week
{ timestamp: +new Date('2024-12-24 00:09:00'), id: 9 }, // new week/month / year daily reach retention
{ timestamp: +new Date('2025-01-01 00:09:00'), id: 10 }, // same day/week/month/year
{ timestamp: +new Date('2025-01-01 00:10:00'), id: 11 }, // new day / week / month
{ timestamp: +new Date('2025-12-31 00:09:00'), id: 12 }, // same day/week/month/year
{ timestamp: +new Date('2025-12-31 00:09:00'), id: 13 }, // new month /year
],
{
longTermRetention: {
@@ -120,29 +116,29 @@ describe('_getOldEntries() should succeed', () => {
timezone: 'Europe/Paris', // use a time zone here because week definition is timezone bound
},
],
expectedIds: [1, 3, 4, 5],
expectedIds: [1, 2, 3, 4, 5, 10, 12],
testLabel: 'complete test ',
},
{
args: [
0,
[
{ timestamp: +new Date('2024-08-31T09:01:00Z'), id: 1 }, // 4th day too old
{ timestamp: +new Date('2024-09-01T00:10:00Z'), id: 2 }, // third day
{ timestamp: +new Date('2024-09-01T14:20:00Z'), id: 3 }, // second day, oldest
{ timestamp: +new Date('2024-09-02T00:22:00Z'), id: 4 }, // second day
{ timestamp: +new Date('2024-09-03T14:00:00Z'), id: 5 }, // same day oldest
{ timestamp: +new Date('2024-09-04T00:09:00Z'), id: 6 }, // same day in NZ
{ timestamp: +new Date('2024-09-04T00:10:00Z'), id: 7 }, // most recent
{ timestamp: +new Date('2024-09-01 00:01:00'), id: 1 }, // thrid day too old
{ timestamp: +new Date('2024-09-01 00:10:00'), id: 2 }, // thrid day
{ timestamp: +new Date('2024-09-01 00:20:00'), id: 3 }, // second day, too old
{ timestamp: +new Date('2024-09-02 00:22:00'), id: 4 }, // second day too old
{ timestamp: +new Date('2024-09-03 00:20:00'), id: 5 }, // second day in NZ
{ timestamp: +new Date('2024-09-04 00:09:00'), id: 6 }, // same day in NZ
{ timestamp: +new Date('2024-09-04 00:10:00'), id: 7 }, // most recent
],
{
longTermRetention: {
daily: { retention: 3 },
},
timezone: 'Pacific/Auckland', // GMT +12 (NZST in September)
timezone: 'Pacific/Auckland', // GMT +6
},
],
expectedIds: [1, 4, 6, 7],
expectedIds: [1, 2, 3, 6],
testLabel: 'should handle timezone ',
},
]
@@ -153,20 +149,13 @@ describe('_getOldEntries() should succeed', () => {
assert.strictEqual(
oldEntries.length,
expectedIds.length,
`different length , ${JSON.stringify({ expectedIds, oldEntries: oldEntries.map(({ id }) => id) })}`
`different length , ${JSON.stringify({ oldEntries, expectedIds })}`
)
for (let i = 0; i < expectedIds.length; i++) {
if (i > 0) {
assert.strictEqual(
expectedIds[i - 1] < expectedIds[i],
true,
`expect ids in ascending order ${expectedIds[i - 1]} ${expectedIds[i]}`
)
}
assert.strictEqual(
oldEntries[i].id,
expectedIds[i],
`different id , ${JSON.stringify({ i, expectedIds, oldEntries: oldEntries.map(({ id }) => id) })}`
`different id , ${JSON.stringify({ i, expectedIds, oldEntries })}`
)
}
})
@@ -283,19 +272,6 @@ describe('_getOldEntries() should fail when called incorrectly', () => {
],
testLabel: 'broken date ',
},
{
args: [
1,
[{ obj: 1 }, 'toto', Date.now()],
{
longTermRetention: {
daily: { retention: 5 },
},
timezone: 'Europe/Paris',
},
],
testLabel: 'should fail if LTR without timestamp',
},
]
for (const { args, testLabel } of tests) {

View File

@@ -4,15 +4,11 @@ import { asyncMap } from '@xen-orchestra/async-map'
import { CancelToken } from 'promise-toolbox'
import { compareVersions } from 'compare-versions'
import { defer } from 'golike-defer'
import { Task } from '@vates/task'
import { cancelableMap } from './_cancelableMap.mjs'
import { Task } from './Task.mjs'
import pick from 'lodash/pick.js'
import { BASE_DELTA_VDI, CONTENT_KEY, COPY_OF, VM_UUID } from './_otherConfig.mjs'
import { VHD_MAX_SIZE, XapiDiskSource } from '@xen-orchestra/xapi'
import { toVhdStream } from 'vhd-lib/disk-consumer/index.mjs'
import { toQcow2Stream } from '@xen-orchestra/qcow2'
import { BASE_DELTA_VDI, COPY_OF, VM_UUID } from './_otherConfig.mjs'
const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value])
@@ -23,7 +19,7 @@ export async function exportIncrementalVm(
) {
// refs of VM's VDIs → base's VDIs.
const disks = {}
const streams = {}
const vdis = {}
const vbds = {}
await cancelableMap(cancelToken, vm.$VBDs, async (cancelToken, vbd) => {
@@ -56,14 +52,32 @@ export async function exportIncrementalVm(
$snapshot_of$uuid: vdi.$snapshot_of?.uuid,
$SR$uuid: vdi.$SR.uuid,
}
disks[vdiRef] = new XapiDiskSource({
vdiRef,
xapi: vm.$xapi,
baseRef: baseVdi?.$ref,
nbdConcurrency,
preferNbd,
})
await disks[vdiRef].init()
try {
streams[`${vdiRef}.vhd`] = await vdi.$exportContent({
baseRef: baseVdi?.$ref,
cancelToken,
format: 'vhd',
nbdConcurrency,
preferNbd,
})
} catch (err) {
if (err.code === 'VDI_CANT_DO_DELTA') {
// fall back to a base
Task.info(`Can't do delta, will try to get a full stream`, { vdi })
streams[`${vdiRef}.vhd`] = await vdi.$exportContent({
cancelToken,
format: 'vhd',
nbdConcurrency,
preferNbd,
})
// only warn if the fall back succeed
Task.warning(`Can't do delta with this vdi, transfer will be a full`, {
vdi,
})
} else {
throw err
}
}
})
const suspendVdi = vm.$suspend_VDI
@@ -73,13 +87,10 @@ export async function exportIncrementalVm(
...suspendVdi,
$SR$uuid: suspendVdi.$SR.uuid,
}
disks[vdiRef] = new XapiDiskSource({
vdiRef: suspendVdi.$ref,
xapi: vm.$xapi,
nbdConcurrency,
preferNbd,
streams[`${vdiRef}.vhd`] = await suspendVdi.$exportContent({
cancelToken,
format: 'vhd',
})
await disks[vdiRef].init()
}
const vifs = {}
@@ -93,13 +104,11 @@ export async function exportIncrementalVm(
}
})
// Get a fresh list of VM's VTPM to avoid `vm.VTPMs: [undefined]`
const vmVtpms = await vm.$xapi.getField('VM', vm.$ref, 'VTPMs')
const vtpms = await Promise.all(
vmVtpms.map(async vtpmRef => {
vm.$VTPMs.map(async vtpm => {
let content
try {
content = await vm.$xapi.call('VTPM.get_contents', vtpmRef)
content = await vm.$xapi.call('VTPM.get_contents', vtpm.$ref)
} catch (err) {
console.error(err)
}
@@ -107,24 +116,31 @@ export async function exportIncrementalVm(
})
)
return {
version: '1.1.0',
vbds,
vdis,
vifs,
vm: {
...vm,
return Object.defineProperty(
{
version: '1.1.0',
vbds,
vdis,
vifs,
vm: {
...vm,
},
vtpms,
},
vtpms,
disks,
}
'streams',
{
configurable: true,
value: streams,
writable: true,
}
)
}
export const importIncrementalVm = defer(async function importIncrementalVm(
$defer,
incrementalVm,
sr,
{ cancelToken = CancelToken.none, newMacAddresses = false, targetRef = undefined } = {}
{ cancelToken = CancelToken.none, newMacAddresses = false } = {}
) {
const { version } = incrementalVm
if (compareVersions(version, '1.0.0') < 0) {
@@ -136,106 +152,44 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
const vdiRecords = incrementalVm.vdis
// When targetRef is provided, update the existing VM instead of creating a new one.
const targetVm = targetRef !== undefined ? xapi.getObjectByRef(targetRef, undefined) : undefined
const isUpdate = targetVm !== undefined && !targetVm.is_a_snapshot && !targetVm.is_a_template
// 0. Create suspend_VDI (only when creating a new VM).
// 0. Create suspend_VDI
let suspendVdi
if (!isUpdate) {
if (vmRecord.suspend_VDI !== undefined && vmRecord.suspend_VDI !== 'OpaqueRef:NULL') {
const vdi = vdiRecords[vmRecord.suspend_VDI]
if (vdi === undefined) {
Task.warning('Suspend VDI not available for this suspended VM', {
vm: pick(vmRecord, 'uuid', 'name_label', 'suspend_VDI'),
})
} else {
suspendVdi = await xapi.getRecord('VDI', await xapi.VDI_create(vdi))
$defer.onFailure(() => suspendVdi.$destroy())
}
if (vmRecord.suspend_VDI !== undefined && vmRecord.suspend_VDI !== 'OpaqueRef:NULL') {
const vdi = vdiRecords[vmRecord.suspend_VDI]
if (vdi === undefined) {
Task.warning('Suspend VDI not available for this suspended VM', {
vm: pick(vmRecord, 'uuid', 'name_label', 'suspend_VDI'),
})
} else {
suspendVdi = await xapi.getRecord('VDI', await xapi.VDI_create(vdi))
$defer.onFailure(() => suspendVdi.$destroy())
}
}
// 1. Create the VM or update the existing one.
let vmRef
if (isUpdate) {
vmRef = targetRef
await Promise.all([
xapi.setFields('VM', vmRef, {
actions_after_crash: vmRecord.actions_after_crash,
actions_after_reboot: vmRecord.actions_after_reboot,
actions_after_shutdown: vmRecord.actions_after_shutdown,
domain_type: vmRecord.domain_type,
ha_restart_priority: vmRecord.ha_restart_priority,
has_vendor_device: vmRecord.has_vendor_device,
HVM_boot_params: vmRecord.HVM_boot_params,
HVM_boot_policy: vmRecord.HVM_boot_policy,
HVM_shadow_multiplier: vmRecord.HVM_shadow_multiplier,
memory_dynamic_max: vmRecord.memory_dynamic_max,
memory_dynamic_min: vmRecord.memory_dynamic_min,
memory_static_max: vmRecord.memory_static_max,
memory_static_min: vmRecord.memory_static_min,
name_label: vmRecord.name_label,
name_description: vmRecord.name_description,
order: vmRecord.order,
other_config: vmRecord.other_config,
platform: vmRecord.platform,
PV_args: vmRecord.PV_args,
PV_bootloader: vmRecord.PV_bootloader,
PV_bootloader_args: vmRecord.PV_bootloader_args,
PV_kernel: vmRecord.PV_kernel,
PV_legacy_args: vmRecord.PV_legacy_args,
PV_ramdisk: vmRecord.PV_ramdisk,
recommendations: vmRecord.recommendations,
shutdown_delay: vmRecord.shutdown_delay,
start_delay: vmRecord.start_delay,
suspend_SR: vmRecord.suspend_SR,
tags: vmRecord.tags,
user_version: vmRecord.user_version,
VCPUs_at_startup: vmRecord.VCPUs_at_startup,
VCPUs_max: vmRecord.VCPUs_max,
VCPUs_params: vmRecord.VCPUs_params,
xenstore_data: vmRecord.xenstore_data,
}),
targetVm.update_blocked_operations({
// 1. Create the VM.
const vmRef = await xapi.VM_create(
{
...vmRecord,
affinity: undefined,
blocked_operations: {
...vmRecord.blocked_operations,
start: 'Importing…',
start_on: 'Importing…',
}),
])
} else {
vmRef = await xapi.VM_create(
{
...vmRecord,
affinity: undefined,
blocked_operations: {
...vmRecord.blocked_operations,
start: 'Importing…',
start_on: 'Importing…',
},
ha_always_run: false,
is_a_template: false,
name_label: '[Importing…] ' + vmRecord.name_label,
},
{
bios_strings: vmRecord.bios_strings,
generateMacSeed: newMacAddresses,
suspend_VDI: suspendVdi?.$ref,
}
)
$defer.onFailure.call(xapi, 'VM_destroy', vmRef)
}
ha_always_run: false,
is_a_template: false,
name_label: '[Importing…] ' + vmRecord.name_label,
},
{
bios_strings: vmRecord.bios_strings,
generateMacSeed: newMacAddresses,
suspend_VDI: suspendVdi?.$ref,
}
)
$defer.onFailure.call(xapi, 'VM_destroy', vmRef)
// 2. Delete all VBDs which may have been created by the import.
// In update mode, also collect the VDI refs so orphaned VDIs can be destroyed after new ones are created.
const existingVbdRefs = await xapi.getField('VM', vmRef, 'VBDs')
const oldVdiRefs = new Set(
isUpdate
? (await asyncMap(existingVbdRefs, ref => xapi.getField('VBD', ref, 'VDI'))).filter(
ref => ref !== undefined && ref !== 'OpaqueRef:NULL'
)
: []
)
await asyncMap(existingVbdRefs, ref => ignoreErrors.call(xapi.call('VBD.destroy', ref)))
await asyncMap(await xapi.getField('VM', vmRef, 'VBDs'), ref => ignoreErrors.call(xapi.call('VBD.destroy', ref)))
// 3. Create VDIs & VBDs.
const vbdRecords = incrementalVm.vbds
@@ -245,19 +199,11 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
const vdi = vdiRecords[vdiRef]
let newVdi
if (vdi.baseVdi?.$ref !== undefined) {
if (isUpdate) {
// In update mode, reuse the existing target VDI directly — no clone needed.
newVdi = vdi.baseVdi
oldVdiRefs.delete(newVdi.$ref)
} else {
newVdi = await xapi.getRecord('VDI', await xapi.VDI_clone(vdi.baseVdi.$ref))
$defer.onFailure(() => newVdi.$destroy())
}
if (vdi.baseVdi !== undefined) {
newVdi = await xapi.getRecord('VDI', await vdi.baseVdi.$clone())
$defer.onFailure(() => newVdi.$destroy())
await newVdi.update_other_config(COPY_OF, vdi.uuid)
if (vdi.other_config[CONTENT_KEY] !== undefined) {
await newVdi.update_other_config(CONTENT_KEY, vdi.other_config[CONTENT_KEY])
}
if (vdi.virtual_size > newVdi.virtual_size) {
await newVdi.$callAsync('resize', vdi.virtual_size)
}
@@ -283,16 +229,6 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
newVdis[vdiRef] = newVdi
})
// 3.5. Destroy old VDIs that are no longer attached to the VM.
// Uses ignoreErrors because some storage backends refuse to destroy a VDI that still has snapshot children;
// those VDIs will become truly orphaned once the old snapshot VMs are cleaned up by _deleteOldEntries.
await asyncMap([...oldVdiRefs], ref => ignoreErrors.call(xapi.call('VDI.destroy', ref)))
// 4. For updates, destroy existing VIFs before recreating them.
if (isUpdate) {
await asyncMap(await xapi.getField('VM', vmRef, 'VIFs'), ref => ignoreErrors.call(xapi.call('VIF.destroy', ref)))
}
const networksByNameLabelByVlan = {}
let defaultNetwork
Object.values(xapi.objects.all).forEach(object => {
@@ -308,28 +244,21 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
}
})
const { disks } = incrementalVm
const { streams } = incrementalVm
await Promise.all([
// Import VDI contents.
cancelableMap(cancelToken, Object.entries(newVdis), async (cancelToken, [id, vdi]) => {
for (const disk of ensureArray(disks[id])) {
if (disk === null) {
// we restore a backup and reuse completely a local snapshot
for (let stream of ensureArray(streams[`${id}.vhd`])) {
if (stream === null) {
// we restore a backup and reuse completly a local snapshot
continue
}
await xapi.setField('VDI', vdi.$ref, 'name_label', `[Importing] ${vdiRecords[id].name_label}`)
let stream, format
if (vdi.virtual_size > VHD_MAX_SIZE) {
stream = await toQcow2Stream(disk)
format = 'qcow2'
} else {
stream = await toVhdStream(disk)
format = 'vhd'
if (typeof stream === 'function') {
stream = await stream()
}
await vdi.$importContent(stream, { cancelToken, format })
await xapi.setField('VDI', vdi.$ref, 'name_label', `[Importing] ${vdiRecords[id].name_label}`)
await vdi.$importContent(stream, { cancelToken, format: 'vhd' })
await xapi.setField('VDI', vdi.$ref, 'name_label', vdiRecords[id].name_label)
}
}),
@@ -365,26 +294,16 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
}
}),
])
// recreate VTPMs
await Promise.all(
(incrementalVm.vtpms ?? []).map(async contents => {
await xapi.VTPM_create({ VM: vmRef, contents })
})
)
// recreate vtpm (note there is normally only one VTPM per VM at most)
const existingVtpmRefs = await xapi.getField('VM', vmRef, 'VTPMs')
for (const vtpmRef of existingVtpmRefs ?? []) {
await xapi.call('VTPM_destroy', vtpmRef)
}
for (const contents of incrementalVm.vtpms ?? []) {
await xapi.VTPM_create({ VM: vmRef, contents })
}
const vm = await xapi.getRecord('VM', vmRef)
await Promise.all([
vmRecord.ha_always_run && xapi.setField('VM', vmRef, 'ha_always_run', true),
xapi.setField('VM', vmRef, 'name_label', vmRecord.name_label),
// correctly unlock the VM and reapply the target blocked operations
vm.update_blocked_operations({
start: null,
start_on: null,
...vmRecord.blocked_operations,
}),
incrementalVm.vm.ha_always_run && xapi.setField('VM', vmRef, 'ha_always_run', true),
xapi.setField('VM', vmRef, 'name_label', incrementalVm.vm.name_label),
])
return vmRef

View File

@@ -44,7 +44,7 @@ const isValidTar = async (handler, size, fd) => {
return buf.every(_ => _ === 0)
}
// TODO: find a heuristic for compressed files
// TODO: find an heuristic for compressed files
export async function isValidXva(path) {
const handler = this._handler

View File

@@ -20,31 +20,13 @@ const IGNORED_PARTITION_TYPES = {
0xd5: true,
0x82: true, // swap
// GPT Linux swap GUID
'0657fd6d-a4ab-43c4-84e5-0933c84b4f4f': true,
}
// MBR LVM type
export const LVM_PARTITION_TYPE_MBR = 0x8e
// GPT LVM type
export const LVM_PARTITION_TYPE_GPT = 'e6d6d379-f507-44c2-a23c-238f2a3df928'
export const LVM_PARTITION_TYPE = 0x8e
const parsePartxLine = createParser({
keyTransform: key => (key === 'UUID' ? 'id' : key.toLowerCase()),
valueTransform: (value, key) => {
if (key === 'start' || key === 'size') {
return +value
}
// For GPT partitions, type is a UUID string
if (key === 'type' && !value.startsWith('0x')) {
return value.toLowerCase()
}
// For MBR partitions, type is a hex number as string (e.g., "0x8e")
if (key === 'type' && value.startsWith('0x')) {
return parseInt(value, 16)
}
return value
},
valueTransform: (value, key) => (key === 'start' || key === 'size' || key === 'type' ? +value : value),
})
// returns an empty array in case of a non-partitioned disk

View File

@@ -28,11 +28,6 @@ export const EXPORTED_SUCCESSFULLY = 'xo:backup:exported'
// the VM ( not the snapshot) uuid
export const VM_UUID = 'xo:backup:vm'
// in `other_config` of source snapshot VDIs and replicated target VDIs
// contains the UUID of the source snapshot VDI whose content this VDI represents
// allows direction-agnostic discovery of common base VDIs (e.g. for failback/reverse replication)
export const CONTENT_KEY = 'xo:backup:contentKey'
async function listVdiRefs(xapi, vmRef) {
return xapi.VM_getDisks(vmRef)
}
@@ -48,7 +43,7 @@ async function getDeltaChainLength(xapi, type, ref) {
}
/**
* set the delta chain length ( number of delta since last base backup) to a VM and its associated VDIs
* set the delta chain lenght ( number of delta since last base backup) to a VM and its associated VDIs
*
* @param {Xapi} xapi
* @param {String} vmRef
@@ -63,7 +58,7 @@ export async function setVmDeltaChainLength(xapi, vmRef, length) {
/**
* Compute the delta chain length of a VM and its associated VDIs
* if there is a discrepancy, use, the highest value
* if there is a discrependcy, use, the highest value
* @param {Xapi} xapi
* @param {String} vmRef
* @returns {Promise}
@@ -75,8 +70,7 @@ export async function getVmDeltaChainLength(xapi, vmRef) {
/**
*
* Reset the other_config field related to backups of a VM and its VDIs
*
* Reset the other_config field of a VM and its VDIs
*
* @param {Xapi} xapi
* @param {String} vmRef
@@ -85,7 +79,6 @@ export async function getVmDeltaChainLength(xapi, vmRef) {
export function resetVmOtherConfig(xapi, vmRef) {
return applyToVmAndVdis(xapi, vmRef, (type, ref) => {
return xapi.setFieldEntries(type, ref, 'other_config', {
[COPY_OF]: null,
[DATETIME]: null,
[DELTA_CHAIN_LENGTH]: null,
[EXPORTED_SUCCESSFULLY]: null,
@@ -97,6 +90,39 @@ export function resetVmOtherConfig(xapi, vmRef) {
})
}
/**
*
* used to ensure compatibiliy with the previous snapshots that were having the config stored only into VM
*
* @param {Xapi} xapi
* @param {String} vmRef
* @returns {Promise}
*/
export async function populateVdisOtherConfig(xapi, vmRef) {
const otherConfig = await xapi.getField('VM', vmRef, 'other_config')
const {
[DATETIME]: datetime,
[DELTA_CHAIN_LENGTH]: chainLength,
[EXPORTED_SUCCESSFULLY]: successfully,
[JOB_ID]: jobId,
[REPLICATED_TO_SR_UUID]: replicatedTo,
[SCHEDULE_ID]: scheduleId,
[VM_UUID]: vmUuid,
} = otherConfig
return applyToVmAndVdis(xapi, vmRef, (type, ref) =>
xapi.setFieldEntries(type, ref, 'other_config', {
[DATETIME]: datetime,
[DELTA_CHAIN_LENGTH]: chainLength,
[EXPORTED_SUCCESSFULLY]: successfully,
[JOB_ID]: jobId,
[REPLICATED_TO_SR_UUID]: replicatedTo,
[SCHEDULE_ID]: scheduleId,
[VM_UUID]: vmUuid,
})
)
}
/**
*
* set the other_config key related to a backup of a VM and its associated VDIs
@@ -126,7 +152,7 @@ export async function setVmOtherConfig(xapi, vmRef, { timestamp, jobId, schedule
}
/**
*
* mark the export of he VM and its VDIs as successful
* mark the export of he VM and its VDIs as successfull
*
* @param {Xapi} xapi
* @param {String} vmRef
@@ -137,24 +163,3 @@ export async function markExportSuccessfull(xapi, vmRef) {
xapi.setFieldEntry(type, ref, 'other_config', EXPORTED_SUCCESSFULLY, 'true')
)
}
/**
* Set CONTENT_KEY on each VDI of a VM snapshot to its own UUID.
*
* This marks each snapshot VDI with a unique content identifier that can be
* propagated to replicated copies for direction-agnostic base VDI discovery
* (e.g. for failback / reverse replication).
*
* @param {Xapi} xapi
* @param {String} snapshotRef
* @returns {Promise}
*/
export async function setVmSnapshotContentKeys(xapi, snapshotRef) {
return applyToVmAndVdis(xapi, snapshotRef, async (type, ref) => {
if (type !== 'VDI') {
return
}
const uuid = await xapi.getField(type, ref, 'uuid')
return xapi.setFieldEntry(type, ref, 'other_config', CONTENT_KEY, uuid)
})
}

View File

@@ -1,5 +1,4 @@
import { asyncMap } from '@xen-orchestra/async-map'
import { Task } from '@vates/task'
import Disposable from 'promise-toolbox/Disposable'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
@@ -7,10 +6,9 @@ import { extractIdsFromSimplePattern } from '../extractIdsFromSimplePattern.mjs'
import { PoolMetadataBackup } from './_PoolMetadataBackup.mjs'
import { XoMetadataBackup } from './_XoMetadataBackup.mjs'
import { DEFAULT_SETTINGS, Abstract } from './_Abstract.mjs'
import { runTask } from './_runTask.mjs'
import { getAdaptersByRemote } from './_getAdaptersByRemote.mjs'
const noop = Function.prototype
const DEFAULT_METADATA_SETTINGS = {
retentionPoolMetadata: 0,
retentionXoMetadata: 0,
@@ -57,16 +55,13 @@ export const Metadata = class MetadataBackupRunner extends Abstract {
poolIds.map(id =>
this._getRecord('pool', id).catch(error => {
// See https://github.com/vatesfr/xen-orchestra/commit/6aa6cfba8ec939c0288f0fa740f6dfad98c43cbb
Task.run(
runTask(
{
properties: {
id,
name: 'get pool record',
type: 'pool',
},
name: 'get pool record',
data: { type: 'pool', id },
},
() => Promise.reject(error)
).catch(noop)
)
})
)
),
@@ -86,11 +81,11 @@ export const Metadata = class MetadataBackupRunner extends Abstract {
if (pools.length !== 0 && settings.retentionPoolMetadata !== 0) {
promises.push(
asyncMap(pools, async pool =>
Task.run(
runTask(
{
properties: {
name: `Starting metadata backup for the pool (${pool.$id}). (${job.id})`,
data: {
id: pool.$id,
name: `Starting metadata backup for the pool (${pool.$id}). (${job.id})`,
pool,
poolMaster: await ignoreErrors.call(pool.$xapi.getRecord('host', pool.master)),
type: 'pool',
@@ -105,17 +100,17 @@ export const Metadata = class MetadataBackupRunner extends Abstract {
schedule,
settings,
}).run()
).catch(noop)
)
)
)
}
if (job.xoMetadata !== undefined && settings.retentionXoMetadata !== 0) {
promises.push(
Task.run(
runTask(
{
properties: {
name: `Starting XO metadata backup. (${job.id})`,
name: `Starting XO metadata backup. (${job.id})`,
data: {
type: 'xo',
},
},
@@ -127,7 +122,7 @@ export const Metadata = class MetadataBackupRunner extends Abstract {
schedule,
settings,
}).run()
).catch(noop)
)
)
}
await Promise.all(promises)

View File

@@ -1,15 +1,14 @@
import { asyncMapSettled } from '@xen-orchestra/async-map'
import Disposable from 'promise-toolbox/Disposable'
import { limitConcurrency } from 'limit-concurrency-decorator'
import { Task } from '@vates/task'
import { extractIdsFromSimplePattern } from '../extractIdsFromSimplePattern.mjs'
import { Task } from '../Task.mjs'
import createStreamThrottle from './_createStreamThrottle.mjs'
import { DEFAULT_SETTINGS, Abstract } from './_Abstract.mjs'
import { getAdaptersByRemote } from './_getAdaptersByRemote.mjs'
import { FullRemote } from './_vmRunners/FullRemote.mjs'
import { IncrementalRemote } from './_vmRunners/IncrementalRemote.mjs'
import { Throttle } from '@vates/generator-toolbox'
import createStreamThrottle from './_createStreamThrottle.mjs'
const noop = Function.prototype
@@ -42,7 +41,6 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
const schedule = this._schedule
const settings = this._settings
const throttleGenerator = new Throttle(settings.maxExportRate)
const throttleStream = createStreamThrottle(settings.maxExportRate)
const config = this._config
@@ -78,7 +76,7 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
}
nTriesByVmId[vmUuid]++
const taskStart = { properties: { id: vmUuid, name: 'backup VM', type: 'VM' } }
const taskStart = { name: 'backup VM', data: { type: 'VM', id: vmUuid } }
const vmSettings = { ...settings, ...allSettings[vmUuid] }
const isLastRun = nTriesByVmId[vmUuid] === vmSettings.nRetriesVmBackupFailures + 1
@@ -91,8 +89,7 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
schedule,
settings: vmSettings,
sourceRemoteAdapter,
throttleGenerator, // for incrementals
throttleStream, // for full
throttleStream,
vmUuid,
}
let vmBackup
@@ -113,27 +110,23 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
taskByVmId[vmUuid] = new Task(taskStart)
}
const task = taskByVmId[vmUuid]
// error has to be caught in the task to prevent its failure, but handled outside the task to execute another task.run()
let taskError
return task
.runInside(async () =>
vmBackup.run().catch(error => {
taskError = error
})
)
.then(result => {
if (taskError === undefined) {
return task.success(result)
.run(async () => {
try {
const result = await vmBackup.run()
task.success(result)
return result
} catch (error) {
if (isLastRun) {
throw error
} else {
Task.warning(`Retry the VM mirror backup due to an error`, {
attempt: nTriesByVmId[vmUuid],
error: error.message,
})
queue.add(vmUuid)
}
}
if (isLastRun) {
return task.failure(taskError)
}
// don't end the task
task.warning(`Retry the VM mirror backup due to an error`, {
attempt: nTriesByVmId[vmUuid],
error: taskError.message,
})
queue.add(vmUuid)
})
.catch(noop)
}

View File

@@ -1,15 +1,15 @@
import { asyncMapSettled } from '@xen-orchestra/async-map'
import Disposable from 'promise-toolbox/Disposable'
import { limitConcurrency } from 'limit-concurrency-decorator'
import { Task } from '@vates/task'
import { extractIdsFromSimplePattern } from '../extractIdsFromSimplePattern.mjs'
import { Task } from '../Task.mjs'
import createStreamThrottle from './_createStreamThrottle.mjs'
import { DEFAULT_SETTINGS, Abstract } from './_Abstract.mjs'
import { runTask } from './_runTask.mjs'
import { getAdaptersByRemote } from './_getAdaptersByRemote.mjs'
import { IncrementalXapi } from './_vmRunners/IncrementalXapi.mjs'
import { FullXapi } from './_vmRunners/FullXapi.mjs'
import { Throttle } from '@vates/generator-toolbox'
import createStreamThrottle from './_createStreamThrottle.mjs'
const noop = Function.prototype
@@ -55,7 +55,6 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
const schedule = this._schedule
const settings = this._settings
const throttleGenerator = new Throttle(settings.maxExportRate)
const throttleStream = createStreamThrottle(settings.maxExportRate)
const config = this._config
@@ -64,12 +63,13 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
Disposable.all(
extractIdsFromSimplePattern(job.srs).map(id =>
this._getRecord('SR', id).catch(error => {
Task.run(
runTask(
{
properties: { id, name: 'get SR record', type: 'SR' },
name: 'get SR record',
data: { type: 'SR', id },
},
() => Promise.reject(error)
).catch(noop)
)
})
)
),
@@ -106,12 +106,11 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
}
return taskByVmId[vmUuid]
}
const vmBackupFailed = async (error, task) => {
const vmBackupFailed = error => {
if (isLastRun) {
return task.failure(error)
throw error
} else {
// don't end the task
task.warning(`Retry the VM backup due to an error`, {
Task.warning(`Retry the VM backup due to an error`, {
attempt: nTriesByVmId[vmUuid],
error: error.message,
})
@@ -125,21 +124,19 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
nTriesByVmId[vmUuid]++
const vmSettings = { ...settings, ...allSettings[vmUuid] }
const taskStart = { properties: { id: vmUuid, name: 'backup VM', type: 'VM' } }
const taskStart = { name: 'backup VM', data: { type: 'VM', id: vmUuid } }
const isLastRun = nTriesByVmId[vmUuid] === vmSettings.nRetriesVmBackupFailures + 1
return this._getRecord('VM', vmUuid).then(
disposableVm =>
Disposable.use(disposableVm, async vm => {
if (taskStart.properties.name_label === undefined) {
taskStart.properties.name_label = vm.name_label
if (taskStart.data.name_label === undefined) {
taskStart.data.name_label = vm.name_label
}
const task = getVmTask()
// error has to be caught in the task to prevent its failure, but handled outside the task to execute another task.run()
let taskError
return task
.runInside(async () => {
.run(async () => {
const opts = {
baseSettings,
config,
@@ -150,7 +147,6 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
schedule,
settings: vmSettings,
srs,
throttleGenerator,
throttleStream,
vm,
}
@@ -165,21 +161,21 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
throw new Error(`Job mode ${job.mode} not implemented`)
}
}
return vmBackup.run().catch(error => {
taskError = error
})
})
.then(result => {
if (taskError === undefined) {
try {
const result = await vmBackup.run()
task.success(result)
} else {
// ending the task with error or not ending the task
vmBackupFailed(taskError, task)
return result
} catch (error) {
vmBackupFailed(error)
}
})
.catch(noop) // errors are handled by logs
}),
error => vmBackupFailed(error, getVmTask())
error =>
getVmTask().run(() => {
vmBackupFailed(error)
})
)
}
const { concurrency } = settings

View File

@@ -1,10 +1,8 @@
import Disposable from 'promise-toolbox/Disposable'
import pTimeout from 'promise-toolbox/timeout'
import { compileTemplate } from '@xen-orchestra/template'
import { runTask } from './_runTask.mjs'
import { RemoteTimeoutError } from './_RemoteTimeoutError.mjs'
import { Task } from '@vates/task'
const noop = Function.prototype
export const DEFAULT_SETTINGS = {
getRemoteTimeout: 300e3,
@@ -38,16 +36,13 @@ export const Abstract = class AbstractRunner {
})
} catch (error) {
// See https://github.com/vatesfr/xen-orchestra/commit/6aa6cfba8ec939c0288f0fa740f6dfad98c43cbb
Task.run(
runTask(
{
properties: {
id: remoteId,
name: 'get remote adapter',
type: 'remote',
},
name: 'get remote adapter',
data: { type: 'remote', id: remoteId },
},
() => Promise.reject(error)
).catch(noop)
)
}
}
}

View File

@@ -1,9 +1,9 @@
import { asyncMap } from '@xen-orchestra/async-map'
import { Task } from '@vates/task'
import { DIR_XO_POOL_METADATA_BACKUPS } from '../RemoteAdapter.mjs'
import { forkStreamUnpipe } from './_forkStreamUnpipe.mjs'
import { formatFilenameDate } from '../_filenameDate.mjs'
import { Task } from '../Task.mjs'
export const PATH_DB_DUMP = '/pool/xmldbdump'
@@ -54,8 +54,8 @@ export class PoolMetadataBackup {
([remoteId, adapter]) =>
Task.run(
{
properties: {
name: `Starting metadata backup for the pool (${pool.$id}) for the remote (${remoteId}). (${job.id})`,
name: `Starting metadata backup for the pool (${pool.$id}) for the remote (${remoteId}). (${job.id})`,
data: {
id: remoteId,
type: 'remote',
},

View File

@@ -1,9 +1,9 @@
import { asyncMap } from '@xen-orchestra/async-map'
import { join } from '@xen-orchestra/fs/path'
import { Task } from '@vates/task'
import { DIR_XO_CONFIG_BACKUPS } from '../RemoteAdapter.mjs'
import { formatFilenameDate } from '../_filenameDate.mjs'
import { Task } from '../Task.mjs'
export class XoMetadataBackup {
constructor({ config, job, remoteAdapters, schedule, settings }) {
@@ -51,8 +51,8 @@ export class XoMetadataBackup {
([remoteId, adapter]) =>
Task.run(
{
properties: {
name: `Starting XO metadata backup for the remote (${remoteId}). (${job.id})`,
name: `Starting XO metadata backup for the remote (${remoteId}). (${job.id})`,
data: {
id: remoteId,
type: 'remote',
},

View File

@@ -0,0 +1,5 @@
import { Task } from '../Task.mjs'
const noop = Function.prototype
export const runTask = (...args) => Task.run(...args).catch(noop) // errors are handled by logs

View File

@@ -2,11 +2,10 @@ import { AbstractRemote } from './_AbstractRemote.mjs'
import { FullRemoteWriter } from '../_writers/FullRemoteWriter.mjs'
import { forkStreamUnpipe } from '../_forkStreamUnpipe.mjs'
import { watchStreamSize } from '../../_watchStreamSize.mjs'
import { AggregatedFullRemoteWriter } from '../_writers/AggregatedFullRemoteWriter.mjs'
export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote {
_getRemoteWriters() {
return [FullRemoteWriter, AggregatedFullRemoteWriter]
_getRemoteWriter() {
return FullRemoteWriter
}
_filterTransferList(transferList) {
@@ -17,7 +16,7 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
const transferList = await this._computeTransferList(({ mode }) => mode === 'full')
for (const metadata of transferList) {
const stream = this._throttleStream(await this._sourceRemoteAdapter.readFullVmBackup(metadata))
const stream = await this._sourceRemoteAdapter.readFullVmBackup(metadata)
const sizeContainer = watchStreamSize(stream)
// @todo shouldn't transfer backup if it will be deleted by retention policy (higher retention on source than destination)
@@ -25,9 +24,8 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
writer =>
writer.run({
stream: forkStreamUnpipe(stream),
// stream will be forked and transformed, it's not safe to attach additional properties to it
// stream will be forked and transformed, it's not safe to attach additionnal properties to it
streamLength: stream.length,
maxStreamLength: stream.maxStreamLength, // on encrypted source
timestamp: metadata.timestamp,
vm: metadata.vm,
vmSnapshot: metadata.vmSnapshot,
@@ -38,6 +36,5 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
// for healthcheck
this._tags = metadata.vm.tags
}
this._hasTransferredData = transferList.length > 0
}
}

View File

@@ -5,45 +5,23 @@ import { FullRemoteWriter } from '../_writers/FullRemoteWriter.mjs'
import { FullXapiWriter } from '../_writers/FullXapiWriter.mjs'
import { watchStreamSize } from '../../_watchStreamSize.mjs'
import { AbstractXapi } from './_AbstractXapi.mjs'
import { AggregatedFullRemoteWriter } from '../_writers/AggregatedFullRemoteWriter.mjs'
import { AggregatedFullXapiWriter } from '../_writers/AggregatedFullXapiWriter.mjs'
const { debug } = createLogger('xo:backups:FullXapiVmBackup')
export const FullXapi = class FullXapiVmBackupRunner extends AbstractXapi {
_getWriters() {
return [FullRemoteWriter, FullXapiWriter, AggregatedFullRemoteWriter, AggregatedFullXapiWriter]
return [FullRemoteWriter, FullXapiWriter]
}
async _mustDoSnapshot() {
_mustDoSnapshot() {
const vm = this._vm
const settings = this._settings
// General setting forcing snapshot at all times
if (settings.unconditionalSnapshot) {
return true
}
// If offline backup option is not checked
// and vm is running, we make a snapshot
if (!settings.offlineBackup && vm.power_state === 'Running') {
return true
}
// General setting used for snapshot retention
// see schedule in backupJob edition
if (settings.snapshotRetention !== 0) {
return true
}
const vdis = await this._xapi.getRecords('VDI', await vm.$getDisks())
const hasNOBAKtag = vdis.some(idx => idx.name_label.includes('[NOBAK]'))
// if there is a disk with NOBAK tag
// we need to make a snapshot
if (hasNOBAKtag) {
return true
}
return (
settings.unconditionalSnapshot ||
(!settings.offlineBackup && vm.power_state === 'Running') ||
settings.snapshotRetention !== 0
)
}
_selectBaseVm() {}
@@ -65,17 +43,10 @@ export const FullXapi = class FullXapiVmBackupRunner extends AbstractXapi {
for (const vdiRef of vdis) {
const vdi = await this._xapi.getRecord('VDI', vdiRef)
// the size a of fully allocated vdi will be virtual_size exactly, it's a gross over evaluation
// of the real stream size in general, since a disk is rarely completely full
// the size a of fully allocated vdi will be virtual_size exaclty, it's a gross over evaluation
// of the real stream size in general, since a disk is never completly full
// vdi.physical_size seems to underevaluate a lot the real disk usage of a VDI, as of 2023-10-30
// xva files are tar archive with data cut in 1MB blocks
// each block is signed with a hash (in xxhash format)
// each of these blocks + hash add one tar header entry in the file
const XVA_BLOCK_SIZE = 1024 * 1024
const nbBlocks = Math.ceil(vdi.virtual_size / XVA_BLOCK_SIZE)
const headersSize = nbBlocks * (512 /* file header */ + 512 /* xxhash header */ + 512) /* xhxhash size */
maxStreamLength += XVA_BLOCK_SIZE * nbBlocks + headersSize
maxStreamLength += vdi.virtual_size
}
const sizeContainer = watchStreamSize(stream)

View File

@@ -3,6 +3,8 @@ import { createLogger } from '@xen-orchestra/log'
import { asyncEach } from '@vates/async-each'
import assert from 'node:assert'
import * as UUID from 'uuid'
import isVhdDifferencingDisk from 'vhd-lib/isVhdDifferencingDisk.js'
import mapValues from 'lodash/mapValues.js'
import { AbstractRemote } from './_AbstractRemote.mjs'
import { forkDeltaExport } from './_forkDeltaExport.mjs'
@@ -10,16 +12,14 @@ import { IncrementalRemoteWriter } from '../_writers/IncrementalRemoteWriter.mjs
import { Disposable } from 'promise-toolbox'
import { openVhd } from 'vhd-lib'
import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
import { SynchronizedDisk, ThrottledDisk } from '@xen-orchestra/disk-transform'
import { AggregatedIncrementalRemoteWriter } from '../_writers/AggregatedIncrementalRemoteWriter.mjs'
const { warn } = createLogger('xo:backups:Incrementalremote')
class IncrementalRemoteVmBackupRunner extends AbstractRemote {
_getRemoteWriters() {
return [IncrementalRemoteWriter, AggregatedIncrementalRemoteWriter]
_getRemoteWriter() {
return IncrementalRemoteWriter
}
// we'll transfer the full list if at least one backup should be transferred
// we'll transfer the full list if at least one backup should be transfered
// to ensure we don't cut the delta chain
_filterTransferList(transferList) {
if (transferList.some(vmBackupMetadata => this._filterPredicate(vmBackupMetadata))) {
@@ -37,7 +37,7 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
return
}
await asyncEach(Object.entries(metadata.vdis), async ([id, vdi]) => {
const isDifferencing = metadata.isVhdDifferencing[id]
const isDifferencing = metadata.isVhdDifferencing[`${id}.vhd`]
if (isDifferencing) {
const vmDir = getVmBackupDir(metadata.vm.uuid)
const path = `${vmDir}/${metadata.vhds[id]}`
@@ -71,13 +71,9 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
// recompute if disks are differencing or not
const isVhdDifferencing = {}
for (const key in incrementalExport.disks) {
let disk = incrementalExport.disks[key]
isVhdDifferencing[key] = disk.isDifferencing()
disk = new ThrottledDisk(disk, this._throttleGenerator)
incrementalExport.disks[key] = new SynchronizedDisk(disk)
}
await asyncEach(Object.entries(incrementalExport.streams), async ([key, stream]) => {
isVhdDifferencing[key] = await isVhdDifferencingDisk(stream)
})
const hasDifferencingDisk = Object.values(isVhdDifferencing).includes(true)
if (metadata.isBase === hasDifferencingDisk) {
warn(`Metadata isBase and real disk value are different`, {
@@ -91,10 +87,11 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
await this._selectBaseVm(metadata)
await this._callWriters(writer => writer.prepare({ isBase: metadata.isBase }), 'writer.prepare()')
incrementalExport.streams = mapValues(incrementalExport.streams, this._throttleStream)
await this._callWriters(
writer =>
writer.transfer({
deltaExport: forkDeltaExport(incrementalExport, writer.constructor.name),
deltaExport: forkDeltaExport(incrementalExport),
isVhdDifferencing,
timestamp: metadata.timestamp,
vm: metadata.vm,
@@ -117,7 +114,6 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
// for healthcheck
this._tags = metadata.vm.tags
}
this._hasTransferredData = transferList.length > 0
}
}

View File

@@ -1,45 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { IncrementalRemote } from './IncrementalRemote.mjs'
const makeAdapter = () => ({
listVmBackups: async () => [],
_handler: { getInfo: async () => ({ available: 0 }), _remote: { id: 'remote-id' } },
})
const makeRunner = ({ distributeBackups = false, schedule } = {}) => {
const remoteId = 'remote-id'
return new IncrementalRemote({
config: {},
job: { id: 'job-id', mode: 'delta', remotes: { id: remoteId }, settings: {}, filter: undefined },
healthCheckSr: undefined,
remoteAdapters: { [remoteId]: makeAdapter() },
schedule,
settings: { distributeBackups, exportRetention: 1, deleteFirst: false, skipDeleteOldEntries: false },
sourceRemoteAdapter: {},
throttleGenerator: {},
throttleStream: {},
vmUuid: 'vm-uuid',
})
}
describe('IncrementalRemote prepare()', () => {
it('prepare() with IncrementalRemoteWriter (distributeBackups: false)', async () => {
const schedule = { id: 'schedule-id' }
const runner = makeRunner({ distributeBackups: false, schedule })
for (const writer of runner._writers) {
await writer.prepare({ isFull: false })
assert.equal(writer._schedule, schedule)
}
})
it('prepare() AggregatedIncrementalRemoteWriter (distributeBackups: true)', async () => {
const schedule = { id: 'schedule-id' }
const runner = makeRunner({ distributeBackups: true, schedule })
const [aggWriter] = runner._writers
await aggWriter.setupWriters()
await aggWriter.prepare({ isFull: false })
assert.equal(aggWriter.props.schedule, schedule)
})
})

View File

@@ -1,12 +1,18 @@
import { asyncEach } from '@vates/async-each'
import { createLogger } from '@xen-orchestra/log'
import { Task } from '@vates/task'
import { pipeline } from 'node:stream'
import isVhdDifferencingDisk from 'vhd-lib/isVhdDifferencingDisk.js'
import keyBy from 'lodash/keyBy.js'
import mapValues from 'lodash/mapValues.js'
import vhdStreamValidator from 'vhd-lib/vhdStreamValidator.js'
import { AbstractXapi } from './_AbstractXapi.mjs'
import { forkDeltaExport } from './_forkDeltaExport.mjs'
import { exportIncrementalVm } from '../../_incrementalVm.mjs'
import { forkDeltaExport } from './_forkDeltaExport.mjs'
import { IncrementalRemoteWriter } from '../_writers/IncrementalRemoteWriter.mjs'
import { IncrementalXapiWriter } from '../_writers/IncrementalXapiWriter.mjs'
import { Task } from '../../Task.mjs'
import { watchStreamSize } from '../../_watchStreamSize.mjs'
import {
DATETIME,
DELTA_CHAIN_LENGTH,
@@ -14,23 +20,16 @@ import {
setVmDeltaChainLength,
markExportSuccessfull,
} from '../../_otherConfig.mjs'
import { ThrottledDisk, SynchronizedDisk } from '@xen-orchestra/disk-transform'
import { AggregatedIncrementalRemoteWriter } from '../_writers/AggregatedIncrementalRemoteWriter.mjs'
import { AggregatedIncrementalXapiWriter } from '../_writers/AggregatedIncrementalXapiWriter.mjs'
const { debug } = createLogger('xo:backups:IncrementalXapiVmBackup')
const noop = Function.prototype
export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends AbstractXapi {
_getWriters() {
return [
IncrementalRemoteWriter,
IncrementalXapiWriter,
AggregatedIncrementalRemoteWriter,
AggregatedIncrementalXapiWriter,
]
return [IncrementalRemoteWriter, IncrementalXapiWriter]
}
async _mustDoSnapshot() {
_mustDoSnapshot() {
return true
}
@@ -46,37 +45,42 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
nbdConcurrency: this._settings.nbdConcurrency,
preferNbd: this._settings.preferNbd,
})
const isVhdDifferencing = {}
let useNbd = false
for (const key in deltaExport.disks) {
let disk = deltaExport.disks[key]
isVhdDifferencing[key] = disk.isDifferencing()
if (!isFull && !isVhdDifferencing[key] && key !== exportedVm.$suspend_VDI?.$ref) {
Task.warning('Backup fell back to a full')
}
useNbd = useNbd || disk.useNbd()
disk = new ThrottledDisk(disk, this._throttleGenerator)
deltaExport.disks[key] = new SynchronizedDisk(disk)
}
if (useNbd) {
// since NBD is network based, if one disk use nbd , all the disk use them
// except the suspended VDI
if (Object.values(deltaExport.streams).some(({ _nbd }) => _nbd)) {
Task.info('Transfer data using NBD')
}
// @todo : reimplement throttle
const isVhdDifferencing = {}
// since isVhdDifferencingDisk is reading and unshifting data in stream
// it should be done BEFORE any other stream transform
await asyncEach(Object.entries(deltaExport.streams), async ([key, stream]) => {
isVhdDifferencing[key] = await isVhdDifferencingDisk(stream)
})
const sizeContainers = mapValues(deltaExport.streams, stream => watchStreamSize(stream))
if (this._settings.validateVhdStreams) {
deltaExport.streams = mapValues(deltaExport.streams, stream => pipeline(stream, vhdStreamValidator, noop))
}
deltaExport.streams = mapValues(deltaExport.streams, this._throttleStream)
const timestamp = Date.now()
await this._callWriters(
writer =>
writer.transfer({
deltaExport: forkDeltaExport(deltaExport, writer.constructor.name),
deltaExport: forkDeltaExport(deltaExport),
isVhdDifferencing,
sizeContainers,
timestamp,
vm,
vmSnapshot: exportedVm,
}),
'writer.transfer()'
)
// we want to control the uuid of the vhd in the chain
// and ensure they are correctly chained
await this._callWriters(
writer =>
writer.updateUuidAndChain({
@@ -98,6 +102,15 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
await markExportSuccessfull(this._xapi, exportedVm.$ref)
}
const size = Object.values(sizeContainers).reduce((sum, { size }) => sum + size, 0)
const end = Date.now()
const duration = end - timestamp
debug('transfer complete', {
duration,
speed: duration !== 0 ? (size * 1e3) / 1024 / 1024 / duration : 0,
size,
})
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
}
@@ -135,6 +148,16 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
const deltaChainLength = Math.max(
...lastExportedVdis.map(({ other_config }) => Number(other_config[DELTA_CHAIN_LENGTH] ?? 0))
)
const fullInterval = this._settings.fullInterval
if (fullInterval !== 0 && fullInterval <= deltaChainLength + 1) {
debug('not using base VM because fullInterval reached', {
fullInterval,
deltaChainLength,
eq: fullInterval < deltaChainLength + 1,
dc1: deltaChainLength + 1,
})
return
}
const srcVdis = keyBy(await xapi.getRecords('VDI', await this._vm.$getDisks()), '$ref')
@@ -163,20 +186,6 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
debug('no base VM found')
return
}
// we do tafter checkbasevdis because we want the writer to know the target VM
// especially on replication when we alwayr update the same VM
const fullInterval = this._settings.fullInterval
if (fullInterval !== 0 && fullInterval <= deltaChainLength + 1) {
debug('not using base VM because fullInterval reached', {
fullInterval,
deltaChainLength,
eq: fullInterval < deltaChainLength + 1,
dc1: deltaChainLength + 1,
})
return
}
baseUuidToSrcVdiUuid.forEach((srcVdiUuid, baseUuid) => {
if (presentBaseVdis.has(baseUuid)) {
debug('found base VDI', {

View File

@@ -1,6 +1,6 @@
import { asyncMap } from '@xen-orchestra/async-map'
import { createLogger } from '@xen-orchestra/log'
import { Task } from '@vates/task'
import { Task } from '../../Task.mjs'
const { debug, warn } = createLogger('xo:backups:AbstractVmRunner')
@@ -83,7 +83,7 @@ export const Abstract = class AbstractVmBackupRunner {
// create a task to have an info in the logs and reports
return Task.run(
{
properties: { name: 'health check' },
name: 'health check',
},
() => {
Task.info(`This VM doesn't match the health check's tags for this schedule`)

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