mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-15 11:30:56 -06:00
Compare commits
5 Commits
web-core/r
...
create-tas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d62433148b | ||
|
|
d44364d4d5 | ||
|
|
9118dc0835 | ||
|
|
97bf035a9f | ||
|
|
1e7ac62c51 |
47
.eslintrc.js
47
.eslintrc.js
@@ -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/'],
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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:
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
5
@vates/async-each/index.d.ts
vendored
5
@vates/async-each/index.d.ts
vendored
@@ -1,5 +0,0 @@
|
||||
export function asyncEach<Item>(
|
||||
iterable: Iterable<Item> | AsyncIterable<Item>,
|
||||
iteratee: (item: Item, index: number, iterable: Iterable<Item> | AsyncIterable<Item>) => Promise<void>,
|
||||
opts?: { concurrency?: number; signal?: AbortSignal; stopOnError?: boolean }
|
||||
): Promise<void>
|
||||
@@ -73,20 +73,20 @@ exports.asyncEach = function asyncEach(iterable, iteratee, { concurrency = 10, s
|
||||
}
|
||||
nextIsRunning = true
|
||||
if (running < concurrency) {
|
||||
try {
|
||||
const cursor = await it.next()
|
||||
if (cursor.done) {
|
||||
next = () => {
|
||||
if (running === 0) {
|
||||
if (errors.length === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new AggregateError(errors))
|
||||
}
|
||||
const cursor = await it.next()
|
||||
if (cursor.done) {
|
||||
next = () => {
|
||||
if (running === 0) {
|
||||
if (errors.length === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new AggregateError(errors))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
++running
|
||||
}
|
||||
} else {
|
||||
++running
|
||||
try {
|
||||
const result = iteratee.call(this, cursor.value, index++, iterable)
|
||||
let then
|
||||
if (result != null && typeof result === 'object' && typeof (then = result.then) === 'function') {
|
||||
@@ -94,9 +94,9 @@ exports.asyncEach = function asyncEach(iterable, iteratee, { concurrency = 10, s
|
||||
} else {
|
||||
onFulfilled(result)
|
||||
}
|
||||
} catch (error) {
|
||||
onRejected(error)
|
||||
}
|
||||
} catch (error) {
|
||||
onRejected(error)
|
||||
}
|
||||
nextIsRunning = false
|
||||
return next()
|
||||
|
||||
@@ -67,17 +67,20 @@ describe('asyncEach', () => {
|
||||
})
|
||||
|
||||
it('stops on first error when stopOnError is true', async () => {
|
||||
const tracker = new assert.CallTracker()
|
||||
|
||||
const error = new Error()
|
||||
const iteratee = spy((_, i) => {
|
||||
const iteratee = tracker.calls((_, i) => {
|
||||
if (i === 1) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}, 2)
|
||||
assert.deepStrictEqual(
|
||||
await rejectionOf(asyncEach(iterable, iteratee, { concurrency: 1, stopOnError: true })),
|
||||
error
|
||||
)
|
||||
assert.deepStrictEqual(iteratee.callCount, 2)
|
||||
|
||||
tracker.verify()
|
||||
})
|
||||
|
||||
it('rejects AggregateError when stopOnError is false', async () => {
|
||||
@@ -97,30 +100,19 @@ describe('asyncEach', () => {
|
||||
})
|
||||
|
||||
it('can be interrupted with an AbortSignal', async () => {
|
||||
const tracker = new assert.CallTracker()
|
||||
|
||||
const ac = new AbortController()
|
||||
const iteratee = spy((_, i) => {
|
||||
const iteratee = tracker.calls((_, i) => {
|
||||
if (i === 1) {
|
||||
ac.abort()
|
||||
}
|
||||
})
|
||||
}, 2)
|
||||
await assert.rejects(asyncEach(iterable, iteratee, { concurrency: 1, signal: ac.signal }), {
|
||||
message: 'asyncEach aborted',
|
||||
})
|
||||
assert.deepStrictEqual(iteratee.callCount, 2)
|
||||
})
|
||||
|
||||
it('handle error on iterable', async () => {
|
||||
const iteratee = spy(async () => {})
|
||||
const iterable = async function* () {
|
||||
for (const value of [1, 2, 3]) {
|
||||
if (value === 2) {
|
||||
throw new Error('iterator error')
|
||||
}
|
||||
await randomDelay()
|
||||
yield value
|
||||
}
|
||||
}
|
||||
await assert.rejects(() => asyncEach(iterable(), iteratee, { concurrency: 1 }), { message: 'iterator error' })
|
||||
tracker.verify()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -24,8 +24,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"types": "index.d.ts",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
```
|
||||
|
||||
|
||||
@@ -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])
|
||||
```
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -62,7 +62,7 @@ exports.parseFlags = function (flags) {
|
||||
case 'ax': info = {read:false, write:true, create:true, append:true, exclusive:true}; break;
|
||||
case 'a+': info = {read:true, write:true, create:true, append:true}; break;
|
||||
case 'ax+': info = {read:true, write:true, create:true, append:true, exclusive:true}; break;
|
||||
default: throw Error("Unknown mode: "+flags); // TODO: throw as `S.err.INVAL`
|
||||
default: throw Error("Uknown mode: "+flags); // TODO: throw as `S.err.INVAL`
|
||||
}
|
||||
if (info.sync) throw Error("Mode not implemented."); // TODO: what would this require of us?
|
||||
if (_dir) info._openDir = true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@vates/fatfs",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.0",
|
||||
"description": "fs implementation on top of raw FAT16/FAT32 block source",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -90,7 +90,7 @@ function startTests(vol, waitTime) {
|
||||
assert(path === file, "Fixed fluffy path matches normal one.");
|
||||
});
|
||||
fs.realpath([BASE_DIR,"non","existent","path"].join('/'), function (e) {
|
||||
assert(e, "Expected error calling fs.realpath on nonexistent file.");
|
||||
assert(e, "Expected error calling fs.realpath on non-existent file.");
|
||||
});
|
||||
|
||||
fs.readdir(BASE_DIR, function (e, arr) {
|
||||
|
||||
@@ -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>`
|
||||
|
||||
|
||||
@@ -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>`
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ export class Throttle {
|
||||
} else {
|
||||
speed = this.#bytesPerSecond
|
||||
}
|
||||
assert.ok(speed >= 0, `speed must be positive, ${speed} computed`)
|
||||
assert.ok(speed > 0, `speed must be greater than zero, ${speed} computed`)
|
||||
return speed
|
||||
}
|
||||
constructor(speed: number | (() => number)) {
|
||||
@@ -29,9 +29,7 @@ export class Throttle {
|
||||
getNextSlot(length: number): { timeout?: ReturnType<typeof setTimeout>; promise?: Promise<unknown> } {
|
||||
assert.notStrictEqual(length, undefined, `throttled stream need to expose a length property }`)
|
||||
assert.ok(length > 0, `throttled stream must expose a positive length property , ${length} given }`)
|
||||
if (this.speed === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const previous = this.#previousSlot
|
||||
const nextSlot = Math.round(previous + (length * 1000) / this.speed)
|
||||
if (nextSlot < Date.now()) {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
const TIMEOUT = Symbol('timeout')
|
||||
|
||||
export class Timeout<T> implements AsyncGenerator {
|
||||
#source: AsyncGenerator<T>
|
||||
#timeout: number
|
||||
@@ -11,30 +8,21 @@ export class Timeout<T> implements AsyncGenerator {
|
||||
this.#timeout = timeout
|
||||
}
|
||||
async next(): Promise<IteratorResult<T>> {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout>
|
||||
|
||||
const sourceNext = this.#source.next()
|
||||
const result = await Promise.race([
|
||||
sourceNext.then(res => {
|
||||
clearTimeout(timeoutHandle)
|
||||
return res
|
||||
}),
|
||||
new Promise<typeof TIMEOUT>(resolve => {
|
||||
timeoutHandle = setTimeout(() => resolve(TIMEOUT), this.#timeout)
|
||||
}),
|
||||
])
|
||||
|
||||
if (result === TIMEOUT) {
|
||||
// stop the source once the in-flight next settles to avoid data loss on the next call
|
||||
// we don't want to await for the end of timeouted code
|
||||
sourceNext.then(
|
||||
() => this.#source.return(undefined).catch(() => {}),
|
||||
() => {} // source already errored, nothing to clean up
|
||||
)
|
||||
throw new Error('Timeout reached')
|
||||
}
|
||||
|
||||
return result
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
const promiseTimeout = new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error('Timeout reached '))
|
||||
}, this.#timeout)
|
||||
})
|
||||
const promiseNext = new Promise((resolve, reject) => {
|
||||
this.#source.next().then(res => {
|
||||
// ensure timetout won't fire later
|
||||
clearTimeout(timeout)
|
||||
resolve(res)
|
||||
}, reject)
|
||||
})
|
||||
// promiseTimeout will never resolve
|
||||
return Promise.race([promiseNext, promiseTimeout]) as Promise<IteratorResult<T>>
|
||||
}
|
||||
return(): Promise<IteratorResult<T>> {
|
||||
return this.#source.return(undefined)
|
||||
|
||||
@@ -64,24 +64,4 @@ describe('Timeout class', () => {
|
||||
|
||||
await assert.rejects(timeout.throw(new Error('Test error')), 'Expected error was not thrown')
|
||||
})
|
||||
|
||||
it('should close the source generator after a timeout', async () => {
|
||||
let sourceClosed = false
|
||||
const slowSource = (async function* () {
|
||||
try {
|
||||
// resolves after 100ms so the in-flight next() eventually settles
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
yield 1
|
||||
} finally {
|
||||
sourceClosed = true
|
||||
}
|
||||
})()
|
||||
|
||||
const timeout = new Timeout(slowSource, 10) // fires before source yields
|
||||
await assert.rejects(() => timeout.next(), /Timeout reached/)
|
||||
|
||||
// wait for the in-flight next() to settle so source.return() can be called
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
assert.strictEqual(sourceClosed, true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"compilerOptions": {
|
||||
"target": "es2023",
|
||||
"module": "ESNext",
|
||||
"types": ["node"], // Only use types from Node.js and packages that are explicitly imported
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"declaration": true, // Generates `.d.ts` files for type safety
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
### `create([opts], [requestListener])`
|
||||
|
||||
Creates a new server instance. `opts` may contain `createServer` and `createSecureServer` factories (defaults to `http.createServer` and `https.createServer`). `requestListener` is registered as a `request` event listener.
|
||||
|
||||
```js
|
||||
import { create } from '@vates/http-server-plus'
|
||||
|
||||
const server = create(function onRequest(req, res) {
|
||||
res.end('Hello')
|
||||
})
|
||||
```
|
||||
|
||||
### `server.listen(opts)`
|
||||
|
||||
Starts listening. Returns a promise that resolves to a human-readable address string, or rejects with `error.niceAddress` set.
|
||||
|
||||
```js
|
||||
// HTTP on a specific port
|
||||
await server.listen({ port: 8080 })
|
||||
|
||||
// HTTPS — detected automatically when cert+key are provided
|
||||
await server.listen({
|
||||
port: 443,
|
||||
cert: fs.readFileSync('certificate.pem'),
|
||||
key: fs.readFileSync('private_key.pem'),
|
||||
})
|
||||
|
||||
// Unix socket
|
||||
await server.listen({ socket: '/var/run/http.sock' })
|
||||
|
||||
// File descriptor
|
||||
await server.listen({ fd: 3 })
|
||||
|
||||
// Systemd socket activation
|
||||
await server.listen({ systemdSocket: 0 })
|
||||
```
|
||||
|
||||
> As a convenience, if `hostname` is `'localhost'`, it will listen on both `127.0.0.1` and `::1`, similar to what Node does when no hostname is provided.
|
||||
|
||||
Multiple endpoints can be started on the same instance:
|
||||
|
||||
```js
|
||||
const [httpAddr, httpsAddr] = await Promise.all([server.listen({ port: 80 }), server.listen({ port: 443, cert, key })])
|
||||
console.log('listening on', httpAddr, httpsAddr)
|
||||
```
|
||||
|
||||
### HTTP/2
|
||||
|
||||
Pass a custom `createSecureServer` factory to enable HTTP/2:
|
||||
|
||||
```js
|
||||
import { createSecureServer } from 'node:http2'
|
||||
import { create } from '@vates/http-server-plus'
|
||||
|
||||
const server = create({ createSecureServer }, app)
|
||||
await server.listen({ port: 443, cert, key })
|
||||
```
|
||||
|
||||
### `server.close()`
|
||||
|
||||
Closes all underlying servers. Returns a promise that resolves once the `close` event is emitted.
|
||||
|
||||
```js
|
||||
await server.close()
|
||||
```
|
||||
@@ -1,91 +0,0 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/http-server-plus
|
||||
|
||||
> Augmented `http.Server`, 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)
|
||||
@@ -1,268 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const EventEmitter = require('events').EventEmitter
|
||||
const formatUrl = require('url').format
|
||||
const fromEvent = require('promise-toolbox/fromEvent')
|
||||
const inherits = require('util').inherits
|
||||
const resolvePath = require('path').resolve
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const forwardedEvents = [
|
||||
'checkContinue',
|
||||
'checkExpectation',
|
||||
'clientError',
|
||||
'connect',
|
||||
'connection',
|
||||
'listening',
|
||||
'request',
|
||||
'secureConnection',
|
||||
'upgrade',
|
||||
]
|
||||
|
||||
function extractProperty(obj, prop) {
|
||||
const value = obj[prop]
|
||||
if (value !== undefined) {
|
||||
delete obj[prop]
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function forOwn(obj, cb) {
|
||||
const keys = Object.keys(obj)
|
||||
for (let i = 0, n = keys.length; i < n; ++i) {
|
||||
cb(obj[keys[i]])
|
||||
}
|
||||
}
|
||||
|
||||
function getSystemdFd(index) {
|
||||
if (process.pid !== +process.env.LISTEN_PID) {
|
||||
throw new Error('systemd sockets are not meant for us (LISTEN_PID)')
|
||||
}
|
||||
|
||||
const listenFds = process.env.LISTEN_FDS
|
||||
if (listenFds == null) {
|
||||
throw new Error('no systemd sockets found (LISTEN_FDS)')
|
||||
}
|
||||
const max = listenFds - 1
|
||||
|
||||
if (index > max) {
|
||||
throw new Error('no such systemd socket found')
|
||||
}
|
||||
|
||||
return index + 3 // 3 = SD_LISTEN_FDS_START
|
||||
}
|
||||
|
||||
const isEmpty = obj => Object.keys(obj).length === 0
|
||||
|
||||
// ===================================================================
|
||||
|
||||
function onNewListener(event, listener) {
|
||||
if (forwardedEvents.includes(event)) {
|
||||
forOwn(this._servers, server => {
|
||||
server.on(event, listener)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onRemoveListener(event, listener) {
|
||||
if (forwardedEvents.includes(event)) {
|
||||
forOwn(this._servers, server => {
|
||||
server.off(event, listener)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function Server(opts) {
|
||||
EventEmitter.call(this)
|
||||
|
||||
this._createServer = (opts != null && opts.createServer) || require('http').createServer
|
||||
this._createSecureServer = (opts != null && opts.createSecureServer) || require('https').createServer
|
||||
this._servers = Object.create(null)
|
||||
this._niceAddresses = Object.create(null)
|
||||
|
||||
this.on('removeListener', onRemoveListener).on('newListener', onNewListener)
|
||||
}
|
||||
inherits(Server, EventEmitter)
|
||||
|
||||
const proto = Server.prototype
|
||||
|
||||
function getAddress(serverId) {
|
||||
return this[serverId].address()
|
||||
}
|
||||
proto.addresses = function Server$addresses() {
|
||||
const servers = this._servers
|
||||
return Object.keys(servers).map(getAddress, servers)
|
||||
}
|
||||
|
||||
function getNiceAddress(serverId) {
|
||||
return this[serverId]
|
||||
}
|
||||
proto.niceAddresses = function Server$niceAddresses() {
|
||||
const niceAddresses = this._niceAddresses
|
||||
return Object.keys(niceAddresses).map(getNiceAddress, niceAddresses)
|
||||
}
|
||||
|
||||
function close(server) {
|
||||
server.close()
|
||||
}
|
||||
|
||||
proto.close = function Server$close(callback) {
|
||||
if (callback) {
|
||||
this.once('close', callback)
|
||||
}
|
||||
|
||||
// Emit the close event even if there are no registered servers.
|
||||
if (isEmpty(this._servers)) {
|
||||
const self = this
|
||||
setImmediate(function () {
|
||||
self.emit('close')
|
||||
})
|
||||
return Promise.resolve()
|
||||
}
|
||||
// Closes each servers.
|
||||
forOwn(this._servers, close)
|
||||
|
||||
return fromEvent(this, 'close')
|
||||
}
|
||||
|
||||
let nextId = 0
|
||||
proto.listen = function Server$listen(opts) {
|
||||
opts = Object.assign({}, opts)
|
||||
|
||||
if (opts.hostname === 'localhost') {
|
||||
return Promise.all([
|
||||
this.listen(Object.assign(opts, { hostname: '::1' })),
|
||||
this.listen(Object.assign(opts, { hostname: '127.0.0.1' })),
|
||||
]).then(addresses => addresses[0])
|
||||
}
|
||||
|
||||
const hostname = extractProperty(opts, 'hostname')
|
||||
let port = extractProperty(opts, 'port')
|
||||
const systemdSocket = extractProperty(opts, 'systemdSocket')
|
||||
let fd = extractProperty(opts, 'fd')
|
||||
let socket = extractProperty(opts, 'socket')
|
||||
|
||||
const servers = this._servers
|
||||
|
||||
const niceAddresses = this._niceAddresses
|
||||
|
||||
let server, protocol
|
||||
if (opts.pfx || opts.SNICallback || (opts.cert && opts.key)) {
|
||||
server = this._createSecureServer(opts)
|
||||
protocol = 'https'
|
||||
} else {
|
||||
server = this._createServer(opts)
|
||||
protocol = 'http'
|
||||
}
|
||||
|
||||
const id = nextId++
|
||||
servers[id] = server
|
||||
|
||||
if (systemdSocket != null) {
|
||||
fd = getSystemdFd(systemdSocket)
|
||||
}
|
||||
|
||||
// Compute a temporary nice address to display in case of error.
|
||||
let niceAddress
|
||||
if (fd != null) {
|
||||
server.listen({ fd })
|
||||
niceAddress = protocol + '://<fd:' + fd + '>'
|
||||
} else if (socket != null) {
|
||||
socket = resolvePath(socket)
|
||||
server.listen(socket)
|
||||
niceAddress = protocol + '://' + socket
|
||||
} else {
|
||||
if (port == null) {
|
||||
port = protocol === 'https' ? 443 : 80
|
||||
}
|
||||
server.listen(port, hostname)
|
||||
niceAddress = formatUrl({
|
||||
protocol,
|
||||
|
||||
// Hostname default to localhost.
|
||||
hostname: hostname || 'localhost',
|
||||
|
||||
// No port means random, unknown for now.
|
||||
port: port || '<unknown>',
|
||||
})
|
||||
}
|
||||
|
||||
const emit = this.emit.bind(this)
|
||||
server.once('close', function onClose() {
|
||||
delete servers[id]
|
||||
delete niceAddresses[id]
|
||||
|
||||
if (isEmpty(servers)) {
|
||||
emit('close')
|
||||
}
|
||||
})
|
||||
|
||||
server.on('error', function onError() {
|
||||
delete servers[id]
|
||||
|
||||
// FIXME: Should it be forwarded and be fatal if there are no
|
||||
// listeners?
|
||||
})
|
||||
|
||||
forwardedEvents.forEach(event => {
|
||||
this.listeners(event).forEach(listener => {
|
||||
server.on(event, listener)
|
||||
})
|
||||
})
|
||||
|
||||
return fromEvent(server, 'listening').then(
|
||||
function () {
|
||||
const address = server.address()
|
||||
const niceAddress =
|
||||
typeof address === 'string'
|
||||
? protocol + '://' + address
|
||||
: formatUrl({
|
||||
protocol,
|
||||
hostname: address.address,
|
||||
port: address.port,
|
||||
})
|
||||
niceAddresses[id] = niceAddress
|
||||
return niceAddress
|
||||
},
|
||||
function (error) {
|
||||
error.niceAddress = niceAddress
|
||||
throw error
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function ref(server) {
|
||||
server.ref()
|
||||
}
|
||||
|
||||
proto.ref = function Server$ref() {
|
||||
forOwn(this._servers, ref)
|
||||
}
|
||||
|
||||
function unref(server) {
|
||||
server.unref()
|
||||
}
|
||||
|
||||
proto.unref = function Server$unref() {
|
||||
forOwn(this._servers, unref)
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
module.exports = exports = Server
|
||||
|
||||
exports.create = function create(opts, requestListener) {
|
||||
if (typeof opts === 'function') {
|
||||
requestListener = opts
|
||||
opts = undefined
|
||||
}
|
||||
|
||||
const server = new Server(opts)
|
||||
if (requestListener !== undefined) {
|
||||
server.on('request', requestListener)
|
||||
}
|
||||
return server
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const assert = require('assert/strict')
|
||||
const { EventEmitter } = require('events')
|
||||
const resolvePath = require('path').resolve
|
||||
const { describe, it } = require('node:test')
|
||||
|
||||
const HttpServerPlus = require('./')
|
||||
|
||||
// ===================================================================
|
||||
|
||||
// Creates a minimal mock server that can be used as a drop-in for http/https.Server.
|
||||
// `address` is the value returned by mock.address() — use an object for TCP, a string for sockets.
|
||||
function createMock(address) {
|
||||
const mock = new EventEmitter()
|
||||
mock.listen = function () {
|
||||
setImmediate(() => this.emit('listening'))
|
||||
}
|
||||
mock.address = () => address
|
||||
mock.close = function () {
|
||||
setImmediate(() => this.emit('close'))
|
||||
}
|
||||
return mock
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
describe('HttpServerPlus', function () {
|
||||
describe('#create()', function () {
|
||||
it('creates a new instance', function () {
|
||||
assert(HttpServerPlus.create() instanceof HttpServerPlus)
|
||||
})
|
||||
|
||||
it('can register a `request` listener', function () {
|
||||
const listener = function () {}
|
||||
const server = HttpServerPlus.create(listener)
|
||||
|
||||
assert.deepEqual(server.listeners('request'), [listener])
|
||||
})
|
||||
})
|
||||
|
||||
describe('.addresses()', function () {
|
||||
it('returns the list of addresses the server is listening at', function () {
|
||||
const addr = { address: '127.0.0.1', port: 8080, family: 'IPv4' }
|
||||
const mock = createMock(addr)
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
return server.listen({ port: 8080 }).then(function () {
|
||||
assert.deepEqual(server.addresses(), [addr])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('.niceAddresses()', function () {
|
||||
it('returns the list of human readable addresses the server is listening at', function () {
|
||||
const mock = createMock({ address: '127.0.0.1', port: 8080 })
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
return server.listen({ hostname: '127.0.0.1', port: 8080 }).then(function (address) {
|
||||
assert.deepEqual(server.niceAddresses(), [address])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('secureConnection event forwarding', function () {
|
||||
function createMockHttpsServer() {
|
||||
const server = new EventEmitter()
|
||||
server.listen = function () {
|
||||
setImmediate(() => this.emit('listening'))
|
||||
}
|
||||
server.address = () => ({ address: '127.0.0.1', port: 8443 })
|
||||
server.close = function () {
|
||||
setImmediate(() => this.emit('close'))
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
function createServerWithMock() {
|
||||
let mockServer
|
||||
const server = HttpServerPlus.create({
|
||||
createSecureServer: () => {
|
||||
mockServer = createMockHttpsServer()
|
||||
return mockServer
|
||||
},
|
||||
})
|
||||
return {
|
||||
server,
|
||||
getMock: () => mockServer,
|
||||
}
|
||||
}
|
||||
|
||||
it('forwards secureConnection to listeners registered before listen()', function () {
|
||||
const { server, getMock } = createServerWithMock()
|
||||
|
||||
const received = []
|
||||
server.on('secureConnection', socket => received.push(socket))
|
||||
|
||||
const fakeSocket = {}
|
||||
return server.listen({ cert: 'fake-cert', key: 'fake-key' }).then(function () {
|
||||
getMock().emit('secureConnection', fakeSocket)
|
||||
assert.deepEqual(received, [fakeSocket])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards secureConnection to listeners registered after listen()', function () {
|
||||
const { server, getMock } = createServerWithMock()
|
||||
|
||||
return server.listen({ cert: 'fake-cert', key: 'fake-key' }).then(function () {
|
||||
const received = []
|
||||
server.on('secureConnection', socket => received.push(socket))
|
||||
|
||||
const fakeSocket = {}
|
||||
getMock().emit('secureConnection', fakeSocket)
|
||||
assert.deepEqual(received, [fakeSocket])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('stops forwarding secureConnection after listener is removed', function () {
|
||||
const { server, getMock } = createServerWithMock()
|
||||
|
||||
const received = []
|
||||
const listener = socket => received.push(socket)
|
||||
server.on('secureConnection', listener)
|
||||
|
||||
return server.listen({ cert: 'fake-cert', key: 'fake-key' }).then(function () {
|
||||
server.off('secureConnection', listener)
|
||||
|
||||
getMock().emit('secureConnection', {})
|
||||
assert.deepEqual(received, [])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('.listen()', function () {
|
||||
it('can use a host:port', function () {
|
||||
let listenArgs
|
||||
const mock = createMock({ address: '127.0.0.1', port: 8080 })
|
||||
mock.listen = function (...args) {
|
||||
listenArgs = args
|
||||
setImmediate(() => this.emit('listening'))
|
||||
}
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
return server.listen({ hostname: '127.0.0.1', port: 8080 }).then(function () {
|
||||
assert.deepEqual(listenArgs, [8080, '127.0.0.1'])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('can use a systemd socket', function () {
|
||||
const SD_LISTEN_FDS_START = 3
|
||||
const SOCKET_INDEX = 0
|
||||
|
||||
let listenArgs
|
||||
const mock = createMock('http://<fd:3>')
|
||||
mock.listen = function (...args) {
|
||||
listenArgs = args
|
||||
setImmediate(() => this.emit('listening'))
|
||||
}
|
||||
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
const env = process.env
|
||||
env.LISTEN_FDS = String(SOCKET_INDEX + 1)
|
||||
env.LISTEN_PID = String(process.pid)
|
||||
|
||||
return server.listen({ systemdSocket: SOCKET_INDEX }).then(function () {
|
||||
delete env.LISTEN_FDS
|
||||
delete env.LISTEN_PID
|
||||
|
||||
assert.deepEqual(listenArgs, [{ fd: SOCKET_INDEX + SD_LISTEN_FDS_START }])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('can use a socket', function () {
|
||||
const socketPath = '/tmp/http-server-plus-test.sock'
|
||||
let listenArgs
|
||||
const mock = createMock(socketPath)
|
||||
mock.listen = function (...args) {
|
||||
listenArgs = args
|
||||
setImmediate(() => this.emit('listening'))
|
||||
}
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
return server.listen({ socket: socketPath }).then(function () {
|
||||
assert.deepEqual(listenArgs, [resolvePath(socketPath)])
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('can use a HTTPS certificate', function () {
|
||||
let capturedOpts
|
||||
const mock = createMock({ address: '127.0.0.1', port: 8443 })
|
||||
const server = HttpServerPlus.create({
|
||||
createSecureServer: opts => {
|
||||
capturedOpts = opts
|
||||
return mock
|
||||
},
|
||||
})
|
||||
|
||||
return server.listen({ cert: 'my-cert', key: 'my-key', port: 8443 }).then(function (address) {
|
||||
assert.equal(capturedOpts.cert, 'my-cert')
|
||||
assert.equal(capturedOpts.key, 'my-key')
|
||||
assert.ok(address.startsWith('https://'), `expected https URL, got: ${address}`)
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a promise which will resolve once listening', function () {
|
||||
const mock = createMock({ address: '127.0.0.1', port: 8080 })
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
return server.listen({ port: 8080 }).then(function (address) {
|
||||
assert.equal(typeof address, 'string')
|
||||
assert.ok(address.startsWith('http://'), `expected http URL, got: ${address}`)
|
||||
return server.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a promise which will reject if failure', function () {
|
||||
const listenError = new Error('EADDRINUSE')
|
||||
const mock = createMock(null)
|
||||
mock.listen = function () {
|
||||
setImmediate(() => this.emit('error', listenError))
|
||||
}
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
return server.listen({ port: 8080 }).then(
|
||||
function () {
|
||||
assert.fail('should have rejected')
|
||||
},
|
||||
function (err) {
|
||||
assert.equal(err, listenError)
|
||||
assert.equal(typeof err.niceAddress, 'string')
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('.close()', function () {
|
||||
it('closes all servers', function () {
|
||||
const closed = []
|
||||
let callCount = 0
|
||||
|
||||
const server = HttpServerPlus.create({
|
||||
createServer: () => {
|
||||
const id = callCount++
|
||||
const mock = createMock({ address: '127.0.0.1', port: id })
|
||||
mock.close = function () {
|
||||
closed.push(id)
|
||||
setImmediate(() => this.emit('close'))
|
||||
}
|
||||
return mock
|
||||
},
|
||||
})
|
||||
|
||||
return Promise.all([server.listen({ port: 0 }), server.listen({ port: 0 })])
|
||||
.then(function () {
|
||||
return server.close()
|
||||
})
|
||||
.then(function () {
|
||||
assert.equal(closed.length, 2)
|
||||
})
|
||||
})
|
||||
|
||||
it('emit the `close` event when all servers are closed', function () {
|
||||
const mock = createMock({ address: '127.0.0.1', port: 8080 })
|
||||
const server = HttpServerPlus.create({ createServer: () => mock })
|
||||
|
||||
let closeEmitted = false
|
||||
server.on('close', () => {
|
||||
closeEmitted = true
|
||||
})
|
||||
|
||||
return server
|
||||
.listen({ port: 8080 })
|
||||
.then(function () {
|
||||
return server.close()
|
||||
})
|
||||
.then(function () {
|
||||
assert.ok(closeEmitted)
|
||||
})
|
||||
})
|
||||
|
||||
it('emit the `close` event even if there are no servers', function () {
|
||||
const server = HttpServerPlus.create()
|
||||
|
||||
// close() with no servers returns Promise.resolve() immediately (before emitting
|
||||
// 'close' via setImmediate), so we must wait for the event itself, not the promise.
|
||||
return new Promise(function (resolve) {
|
||||
server.close(resolve)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
*/
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -71,17 +71,13 @@ exports.readChunk = readChunk
|
||||
* If `size` bytes are not available to be read, the returned promise is rejected.
|
||||
*
|
||||
* @param {Readable} stream - A readable stream to read from.
|
||||
* @param {number?} [size] - The number of bytes to read for binary streams (ignored for object streams).
|
||||
* @param {number} [size] - The number of bytes to read for binary streams (ignored for object streams).
|
||||
* @returns {Promise<Buffer|string|unknown>} - A Promise that resolves to the read chunk.
|
||||
*/
|
||||
exports.readChunkStrict = async function readChunkStrict(stream, size) {
|
||||
const chunk = await readChunk(stream, size)
|
||||
if (chunk === null) {
|
||||
if (size === undefined) {
|
||||
throw new Error(`stream has ended without data`)
|
||||
} else {
|
||||
throw new Error(`stream has ended without data, was looking for ${size} bytes`)
|
||||
}
|
||||
throw new Error('stream has ended without data')
|
||||
}
|
||||
|
||||
if (size !== undefined && chunk.length !== size) {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.0",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
|
||||
@@ -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) {},
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {},
|
||||
})
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noo
|
||||
Object.defineProperty(taskLog, '$root', { value: taskLog })
|
||||
|
||||
// start of a root task
|
||||
onRootTaskStart(taskLog, event)
|
||||
onRootTaskStart(taskLog)
|
||||
} else {
|
||||
// start of a subtask
|
||||
const parent = taskLogs.get(parentId)
|
||||
@@ -54,10 +54,10 @@ exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noo
|
||||
}
|
||||
|
||||
if (type === 'end' && taskLog.$root === taskLog) {
|
||||
onRootTaskEnd(taskLog, event)
|
||||
onRootTaskEnd(taskLog)
|
||||
}
|
||||
}
|
||||
|
||||
onTaskUpdate(taskLog, event)
|
||||
onTaskUpdate(taskLog)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,27 +7,6 @@ function alreadyEnded() {
|
||||
throw new Error('task has already ended')
|
||||
}
|
||||
|
||||
const serializeErrors = errors => (Array.isArray(errors) ? errors.map(serializeError) : errors)
|
||||
|
||||
// Create a serializable object from an error.
|
||||
//
|
||||
// Otherwise some fields might be non-enumerable and missing from logs.
|
||||
const serializeError = error => {
|
||||
return error instanceof Error
|
||||
? {
|
||||
...error, // Copy enumerable properties.
|
||||
cause: error.cause,
|
||||
code: error.code,
|
||||
errors: serializeErrors(error.errors), // supports AggregateError
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
stack: error.stack,
|
||||
}
|
||||
: error
|
||||
}
|
||||
|
||||
exports.serializeError = serializeError
|
||||
|
||||
// define a read-only, non-enumerable, non-configurable property
|
||||
function define(object, property, value) {
|
||||
Object.defineProperty(object, property, { value })
|
||||
@@ -93,11 +72,7 @@ class Task {
|
||||
_onProgress
|
||||
|
||||
get id() {
|
||||
// Use a compact, sortable, string representation of the creation date,
|
||||
// and add a random part to differentiate tasks created at the same time
|
||||
//
|
||||
// Due to the padding, dates are sortable up to 5188-04-22T11:04:28.415Z
|
||||
return (this.id = Date.now().toString(36).padStart(9, '0') + '-' + Math.random().toString(36).slice(2))
|
||||
return (this.id = Math.random().toString(36).slice(2))
|
||||
}
|
||||
set id(value) {
|
||||
define(this, 'id', value)
|
||||
@@ -161,10 +136,6 @@ class Task {
|
||||
assert.equal(this.#status, PENDING)
|
||||
assert.equal(this.#running, false)
|
||||
|
||||
if (result instanceof Error && result.toJSON === undefined) {
|
||||
result = serializeError(result)
|
||||
}
|
||||
|
||||
this.#emit('end', { status, result })
|
||||
this._onProgress = alreadyEnded
|
||||
this.#status = status
|
||||
|
||||
@@ -4,7 +4,7 @@ const assert = require('node:assert').strict
|
||||
const { describe, it } = require('node:test')
|
||||
|
||||
const { makeOnProgress } = require('./combineEvents.js')
|
||||
const { Task, serializeError } = require('./index.js')
|
||||
const { Task } = require('./index.js')
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
@@ -28,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',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.7.0",
|
||||
"version": "0.6.1",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> }>
|
||||
}>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export type Or<T> = Id<{ __or: T[] }>
|
||||
|
||||
export type Id<T> = { id: T }
|
||||
|
||||
export type IdOr<T> = Id<T> | Or<T>
|
||||
@@ -1,21 +1,7 @@
|
||||
import { Branded } from '../common.mjs'
|
||||
|
||||
export interface VatesTask {
|
||||
_abortController: AbortController
|
||||
_onProgress(data?: unknown): void
|
||||
export type Task = {
|
||||
id: Branded<'task'>
|
||||
|
||||
id: Branded<'vates-task'>
|
||||
status: 'failure' | 'pending' | 'success'
|
||||
|
||||
abort(reason?: unknown): void
|
||||
failure(error?: unknown): void
|
||||
info(message: string, data: unknown): void
|
||||
run<T>(fn: () => T | (() => Promise<T>)): Promise<T>
|
||||
runInside<T>(fn: () => T | (() => Promise<T>)): Promise<T>
|
||||
set(name: string, value: unknown): void
|
||||
start(): void
|
||||
success(result?: unknown): void
|
||||
warning(message: string, data: unknown): void
|
||||
wrap<T>(fn: () => T | (() => Promise<T>)): Promise<T>
|
||||
wrapInside<T>(fn: () => T | (() => Promise<T>)): Promise<T>
|
||||
run: <T>(fn: () => T) => Promise<T>
|
||||
}
|
||||
|
||||
@@ -1,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']>
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/async-map",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.2",
|
||||
"license": "ISC",
|
||||
"description": "Promise.all + map for all iterables",
|
||||
"keywords": [
|
||||
@@ -28,7 +28,7 @@
|
||||
"node": ">=6"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.18.0"
|
||||
"lodash": "^4.17.4"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/decorate-with": "^2.1.0",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"golike-defer": "^0.5.1",
|
||||
"object-hash": "^2.0.1"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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') }
|
||||
}
|
||||
|
||||
155
@xen-orchestra/backups/Task.mjs
Normal file
155
@xen-orchestra/backups/Task.mjs
Normal 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)
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
5
@xen-orchestra/backups/_runners/_runTask.mjs
Normal file
5
@xen-orchestra/backups/_runners/_runTask.mjs
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user