Compare commits

..

51 Commits

Author SHA1 Message Date
Florent BEAUCHAMP
49b28abd12 refactor(@xen-orchestra/backups): rename exportDeltaVm to exportIncrementalVm and importDeltaVM to importIncrementalVm 2023-05-22 10:53:10 +02:00
Florent BEAUCHAMP
1878a8cd44 refactor(@xen-orchestra/backups): _deltaVm => _incrementalVm 2023-05-22 10:49:02 +02:00
Florent BEAUCHAMP
ea24d7cf51 refactor(@xen-orchestra/backups) : rename replication writers to xapi writers 2023-05-22 10:49:02 +02:00
Florent BEAUCHAMP
a064d9ad33 refactor(@xen-orchestra/backups): rename backup writer to remote writer 2023-05-22 10:48:57 +02:00
Florent BEAUCHAMP
4562929ace refactor(@xen-orchestra/backups): split VmBackup in classes 2023-05-22 10:44:38 +02:00
Florent BEAUCHAMP
6e1c67e0fc refactor(@xen-orchestra/backups): split Backup Job in XapiVmBackupJob and MetadatasBackupJob 2023-05-22 10:44:18 +02:00
Florent BEAUCHAMP
375e47dc61 refactor(@xen-orchestra/backups): move Backup , VmBackup, writers and specific method to a private folder 2023-05-22 10:33:30 +02:00
Florent BEAUCHAMP
95d985b8a8 refactor(@xen-orchestra/backups): use a factory to instantiate backup job 2023-05-22 10:32:31 +02:00
Florent BEAUCHAMP
26354ac164 refactor(@xen-orchestra/backups): extract methods from Backup.js
getAdaptersByRemote , RemoteTimeoutError and runTasks
2023-05-22 10:32:21 +02:00
Florent BEAUCHAMP
79cfec3205 refactor(@xen-orchestra/backups): rename delta writers to incremental writers 2023-05-22 10:27:04 +02:00
Florent BEAUCHAMP
f482d4a14c refactor(@xen-orchestra/backups): _VMBackup rename delta to incremental 2023-05-22 10:27:04 +02:00
Julien Fontanet
77b166bb3b chore: update dev deps 2023-05-22 10:01:54 +02:00
Julien Fontanet
76bd54d7de chore: update dev deps 2023-05-17 14:48:41 +02:00
Julien Fontanet
684282f0a4 fix(mixins/Tasks): correctly serialize errors 2023-05-17 11:29:28 +02:00
Julien Fontanet
2459f46c19 feat(xo-cli rest): accept query string in path
Example:
```
xo-cli rest post vms/<uuid>/actions/snapshot?sync
```
2023-05-17 11:27:29 +02:00
Julien Fontanet
5f0466e4d8 feat: release 5.82.2 2023-05-17 10:05:11 +02:00
Gabriel Gunullu
3738edfa83 test(@xen-orchestra/fs): from Jest to test (#6820) 2023-05-17 09:54:51 +02:00
Julien Fontanet
769e27e2cb feat: technical release 2023-05-16 16:32:33 +02:00
Julien Fontanet
8ec5461338 feat(xo-server): 5.114.2 2023-05-16 16:31:54 +02:00
Julien Fontanet
4a2843cb67 feat(@xen-orchestra/proxy): 0.26.23 2023-05-16 16:31:33 +02:00
Julien Fontanet
a0e69a79ab feat(xen-api): 1.3.1 2023-05-16 16:30:54 +02:00
Roni Väyrynen
3da94f18df docs(installation): add findmnt command to sudoers config example (#6835) 2023-05-16 15:20:47 +02:00
Mathieu
17cb59b898 feat(xo-web/host-item): display warning for when HVM disabled (#6834) 2023-05-16 14:58:14 +02:00
Mathieu
315e5c9289 feat(xo-web/proxy): make proxy address editable (#6816) 2023-05-16 12:12:31 +02:00
Julien Fontanet
01ba10fedb fix(xen-api/putResource): really fix (302) redirection with non-stream body
Replaces the incorrect fix in 87e6f7fde

Introduced by ab96c549a

Fixes zammad#13375
Fixes zammad#13952
Fixes zammad#14001
2023-05-15 16:23:18 +02:00
Mathieu
13e7594560 fix(xo-web/SortedTable): handle pending state for collapsed actions (#6831) 2023-05-15 15:27:17 +02:00
Thierry Goettelmann
f9ac2ac84d feat(lite/tooltips): enhance and simplify tooltips (#6760)
- Removed the `disabled` option.
- The tooltip is now disabled when content is an empty string or `false`.
- If content is `true` or `undefined`, it will be extracted from element's `innerText`.
- Moved `v-tooltip` from `InfraHostItem` and `InfraVmItem` to `InfraItemLabel`.
2023-05-15 11:55:43 +02:00
Thierry Goettelmann
09cfac1111 feat(lite): enhance Component Story skeleton generator (#6753)
- Updated form to use our own components
- Added a warning for props whose type cannot be extracted
- Fixed setting name for scopes containing a dash
- Handled cases when a prop can be multiple types
- Better guess of prop type
- Remove `.widget()` for `.model()`
- Remove `.event('update:modelValue')` for `.model()`
2023-05-15 11:23:42 +02:00
Thierry Goettelmann
008f7a30fd feat(lite): add VM tab bar (#6766) 2023-05-15 11:15:52 +02:00
Thierry Goettelmann
ff65dbcba7 feat(lite): extract and update "unreachable hosts modal" (#6745)
Extraction of unreachable host modal to its own component + Move the subtitle to the description.

Refer to #6744 for final design.
2023-05-15 11:11:19 +02:00
ggunullu
264a0d1678 fix(@vates/nbd-client): add custom coverage threshold to tap test
By default, Tap require 100 % coverage of all lines, branches, functions and statements.
We enforce a custom threshold to match the current state of the state and avoid regression.

See https://github.com/vatesfr/xen-orchestra/actions/runs/4956232764/jobs/8866437368
2023-05-15 10:18:02 +02:00
ggunullu
7dcaf454ed fix(eslint): treat *.integ.js as test files
Introduced by 3f73138fc3
2023-05-15 10:18:02 +02:00
Julien Fontanet
17b2756291 feat: release 5.82.1 2023-05-12 16:47:21 +02:00
Julien Fontanet
57e48b5d34 feat: technical release 2023-05-12 15:40:38 +02:00
Julien Fontanet
57ed984e5a feat(xo-web): 5.117.1 2023-05-12 15:40:16 +02:00
Julien Fontanet
100122f388 feat(xo-server): 5.114.1 2023-05-12 15:39:36 +02:00
Julien Fontanet
12d4b3396e feat(@xen-orchestra/proxy): 0.26.22 2023-05-12 15:39:16 +02:00
Julien Fontanet
ab35c710cb feat(@xen-orchestra/backups): 0.36.1 2023-05-12 15:38:46 +02:00
Florent BEAUCHAMP
4bd5b38aeb fix(backups): fix health check task during CR (#6830)
Fixes https://xcp-ng.org/forum/post/62073

`healthCheck` is launched after `cleanVm`, therefore it should be closing the parent task, not `cleanVm`.
2023-05-12 10:45:32 +02:00
Julien Fontanet
836db1b807 fix(xo-web/new/network): correct type for vlan (#6829)
BREAKING CHANGE: API method `network.create` no longer accepts a `string` for `vlan` param.

Fixes https://xcp-ng.org/forum/post/62090

Either `number` or `undefined`, not an empty string.
2023-05-12 10:36:59 +02:00
Julien Fontanet
73d88cc5f1 fix(xo-server/vm.convertToTemplate): handle VBD_IS_EMPTY (#6808)
Fixes https://xcp-ng.org/forum/post/61653
2023-05-12 09:12:41 +02:00
Julien Fontanet
3def66d968 chore(xo-vmdk-to-vhd): move notes.md to docs/
So that it will be correctly ignored when publishing the package.
2023-05-12 09:10:00 +02:00
Gabriel Gunullu
3f73138fc3 fix(test-integration): run integration tests only in ci (#6826)
Fixes issues introduced by

- be6233f
- adc5e7d

After the switching from Jest to Tap/Test, those tests were no longer executed during the test-integration script.
2023-05-11 17:47:48 +02:00
Julien Fontanet
bfe621a21d feat: technical release 2023-05-11 14:35:15 +02:00
Julien Fontanet
32fa792eeb feat(xo-web): 5.117.0 2023-05-11 14:23:02 +02:00
Julien Fontanet
a833050fc2 feat(xo-server): 5.114.0 2023-05-11 14:17:40 +02:00
Julien Fontanet
e7e6294bc3 feat(xo-vmdk-to-vhd): 2.5.4 2023-05-11 14:09:23 +02:00
Julien Fontanet
7c71884e27 feat(@vates/task): 0.1.2 2023-05-11 14:03:57 +02:00
Florent BEAUCHAMP
3e822044f2 fix(xo-vmdk-to-vhd): wait for OVA stream to be written before reading more data (#6800) 2023-05-11 12:23:06 +02:00
Julien Fontanet
d457f5fca4 chore(xo-server): use Task.run() helper 2023-05-11 11:10:00 +02:00
Julien Fontanet
1837e01719 fix(xo-server): new Task() now expects data instead of name option
Introduced by 036f3f6bd
2023-05-11 11:08:31 +02:00
145 changed files with 3404 additions and 2860 deletions

View File

@@ -28,7 +28,7 @@ module.exports = {
},
},
{
files: ['*.{spec,test}.{,c,m}js'],
files: ['*.{integ,spec,test}.{,c,m}js'],
rules: {
'n/no-unpublished-require': 'off',
'n/no-unpublished-import': 'off',

View File

@@ -23,7 +23,7 @@
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"promise-toolbox": "^0.21.0",
"xen-api": "^1.3.0"
"xen-api": "^1.3.1"
},
"devDependencies": {
"tap": "^16.3.0",
@@ -31,6 +31,6 @@
},
"scripts": {
"postversion": "npm publish --access public",
"test-integration": "tap *.spec.js"
"test-integration": "tap --lines 70 --functions 36 --branches 54 --statements 69 *.integ.js"
}
}

View File

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

View File

@@ -7,7 +7,7 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.36.0",
"@xen-orchestra/backups": "^0.36.1",
"@xen-orchestra/fs": "^3.3.4",
"filenamify": "^4.1.0",
"getopts": "^2.2.5",

View File

@@ -1,307 +0,0 @@
'use strict'
const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
const Disposable = require('promise-toolbox/Disposable')
const ignoreErrors = require('promise-toolbox/ignoreErrors')
const pTimeout = require('promise-toolbox/timeout')
const { compileTemplate } = require('@xen-orchestra/template')
const { limitConcurrency } = require('limit-concurrency-decorator')
const { extractIdsFromSimplePattern } = require('./extractIdsFromSimplePattern.js')
const { PoolMetadataBackup } = require('./_PoolMetadataBackup.js')
const { Task } = require('./Task.js')
const { VmBackup } = require('./_VmBackup.js')
const { XoMetadataBackup } = require('./_XoMetadataBackup.js')
const createStreamThrottle = require('./_createStreamThrottle.js')
const noop = Function.prototype
const getAdaptersByRemote = adapters => {
const adaptersByRemote = {}
adapters.forEach(({ adapter, remoteId }) => {
adaptersByRemote[remoteId] = adapter
})
return adaptersByRemote
}
const runTask = (...args) => Task.run(...args).catch(noop) // errors are handled by logs
const DEFAULT_SETTINGS = {
getRemoteTimeout: 300e3,
reportWhen: 'failure',
}
const DEFAULT_VM_SETTINGS = {
bypassVdiChainsCheck: false,
checkpointSnapshot: false,
concurrency: 2,
copyRetention: 0,
deleteFirst: false,
exportRetention: 0,
fullInterval: 0,
healthCheckSr: undefined,
healthCheckVmsWithTags: [],
maxExportRate: 0,
maxMergedDeltasPerRun: Infinity,
offlineBackup: false,
offlineSnapshot: false,
snapshotRetention: 0,
timeout: 0,
useNbd: false,
unconditionalSnapshot: false,
validateVhdStreams: false,
vmTimeout: 0,
}
const DEFAULT_METADATA_SETTINGS = {
retentionPoolMetadata: 0,
retentionXoMetadata: 0,
}
class RemoteTimeoutError extends Error {
constructor(remoteId) {
super('timeout while getting the remote ' + remoteId)
this.remoteId = remoteId
}
}
exports.Backup = class Backup {
constructor({ config, getAdapter, getConnectedRecord, job, schedule }) {
this._config = config
this._getRecord = getConnectedRecord
this._job = job
this._schedule = schedule
this._getSnapshotNameLabel = compileTemplate(config.snapshotNameLabelTpl, {
'{job.name}': job.name,
'{vm.name_label}': vm => vm.name_label,
})
const { type } = job
const baseSettings = { ...DEFAULT_SETTINGS }
if (type === 'backup') {
Object.assign(baseSettings, DEFAULT_VM_SETTINGS, config.defaultSettings, config.vm?.defaultSettings)
this.run = this._runVmBackup
} else if (type === 'metadataBackup') {
Object.assign(baseSettings, DEFAULT_METADATA_SETTINGS, config.defaultSettings, config.metadata?.defaultSettings)
this.run = this._runMetadataBackup
} else {
throw new Error(`No runner for the backup type ${type}`)
}
Object.assign(baseSettings, job.settings[''])
this._baseSettings = baseSettings
this._settings = { ...baseSettings, ...job.settings[schedule.id] }
const { getRemoteTimeout } = this._settings
this._getAdapter = async function (remoteId) {
try {
const disposable = await pTimeout.call(getAdapter(remoteId), getRemoteTimeout, new RemoteTimeoutError(remoteId))
return new Disposable(() => disposable.dispose(), {
adapter: disposable.value,
remoteId,
})
} catch (error) {
// See https://github.com/vatesfr/xen-orchestra/commit/6aa6cfba8ec939c0288f0fa740f6dfad98c43cbb
runTask(
{
name: 'get remote adapter',
data: { type: 'remote', id: remoteId },
},
() => Promise.reject(error)
)
}
}
}
async _runMetadataBackup() {
const schedule = this._schedule
const job = this._job
const remoteIds = extractIdsFromSimplePattern(job.remotes)
if (remoteIds.length === 0) {
throw new Error('metadata backup job cannot run without remotes')
}
const config = this._config
const poolIds = extractIdsFromSimplePattern(job.pools)
const isEmptyPools = poolIds.length === 0
const isXoMetadata = job.xoMetadata !== undefined
if (!isXoMetadata && isEmptyPools) {
throw new Error('no metadata mode found')
}
const settings = this._settings
const { retentionPoolMetadata, retentionXoMetadata } = settings
if (
(retentionPoolMetadata === 0 && retentionXoMetadata === 0) ||
(!isXoMetadata && retentionPoolMetadata === 0) ||
(isEmptyPools && retentionXoMetadata === 0)
) {
throw new Error('no retentions corresponding to the metadata modes found')
}
await Disposable.use(
Disposable.all(
poolIds.map(id =>
this._getRecord('pool', id).catch(error => {
// See https://github.com/vatesfr/xen-orchestra/commit/6aa6cfba8ec939c0288f0fa740f6dfad98c43cbb
runTask(
{
name: 'get pool record',
data: { type: 'pool', id },
},
() => Promise.reject(error)
)
})
)
),
Disposable.all(remoteIds.map(id => this._getAdapter(id))),
async (pools, remoteAdapters) => {
// remove adapters that failed (already handled)
remoteAdapters = remoteAdapters.filter(_ => _ !== undefined)
if (remoteAdapters.length === 0) {
return
}
remoteAdapters = getAdaptersByRemote(remoteAdapters)
// remove pools that failed (already handled)
pools = pools.filter(_ => _ !== undefined)
const promises = []
if (pools.length !== 0 && settings.retentionPoolMetadata !== 0) {
promises.push(
asyncMap(pools, async pool =>
runTask(
{
name: `Starting metadata backup for the pool (${pool.$id}). (${job.id})`,
data: {
id: pool.$id,
pool,
poolMaster: await ignoreErrors.call(pool.$xapi.getRecord('host', pool.master)),
type: 'pool',
},
},
() =>
new PoolMetadataBackup({
config,
job,
pool,
remoteAdapters,
schedule,
settings,
}).run()
)
)
)
}
if (job.xoMetadata !== undefined && settings.retentionXoMetadata !== 0) {
promises.push(
runTask(
{
name: `Starting XO metadata backup. (${job.id})`,
data: {
type: 'xo',
},
},
() =>
new XoMetadataBackup({
config,
job,
remoteAdapters,
schedule,
settings,
}).run()
)
)
}
await Promise.all(promises)
}
)
}
async _runVmBackup() {
const job = this._job
// FIXME: proper SimpleIdPattern handling
const getSnapshotNameLabel = this._getSnapshotNameLabel
const schedule = this._schedule
const settings = this._settings
const throttleStream = createStreamThrottle(settings.maxExportRate)
const config = this._config
await Disposable.use(
Disposable.all(
extractIdsFromSimplePattern(job.srs).map(id =>
this._getRecord('SR', id).catch(error => {
runTask(
{
name: 'get SR record',
data: { type: 'SR', id },
},
() => Promise.reject(error)
)
})
)
),
Disposable.all(extractIdsFromSimplePattern(job.remotes).map(id => this._getAdapter(id))),
() => (settings.healthCheckSr !== undefined ? this._getRecord('SR', settings.healthCheckSr) : undefined),
async (srs, remoteAdapters, healthCheckSr) => {
// remove adapters that failed (already handled)
remoteAdapters = remoteAdapters.filter(_ => _ !== undefined)
// remove srs that failed (already handled)
srs = srs.filter(_ => _ !== undefined)
if (remoteAdapters.length === 0 && srs.length === 0 && settings.snapshotRetention === 0) {
return
}
const vmIds = extractIdsFromSimplePattern(job.vms)
Task.info('vms', { vms: vmIds })
remoteAdapters = getAdaptersByRemote(remoteAdapters)
const allSettings = this._job.settings
const baseSettings = this._baseSettings
const handleVm = vmUuid => {
const taskStart = { name: 'backup VM', data: { type: 'VM', id: vmUuid } }
return this._getRecord('VM', vmUuid).then(
disposableVm =>
Disposable.use(disposableVm, vm => {
taskStart.data.name_label = vm.name_label
return runTask(taskStart, () =>
new VmBackup({
baseSettings,
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
schedule,
settings: { ...settings, ...allSettings[vm.uuid] },
srs,
throttleStream,
vm,
}).run()
)
}),
error =>
runTask(taskStart, () => {
throw error
})
)
}
const { concurrency } = settings
await asyncMapSettled(vmIds, concurrency === 0 ? handleVm : limitConcurrency(concurrency)(handleVm))
}
)
}
}

View File

@@ -3,7 +3,7 @@
const assert = require('assert')
const { formatFilenameDate } = require('./_filenameDate.js')
const { importDeltaVm } = require('./_deltaVm.js')
const { importIncrementalVm } = require('./_incrementalVm.js')
const { Task } = require('./Task.js')
const { watchStreamSize } = require('./_watchStreamSize.js')
@@ -49,7 +49,7 @@ exports.ImportVmBackup = class ImportVmBackup {
const vmRef = isFull
? await xapi.VM_import(backup, srRef)
: await importDeltaVm(backup, await xapi.getRecord('SR', srRef), {
: await importIncrementalVm(backup, await xapi.getRecord('SR', srRef), {
...this._importDeltaVmSettings,
detectBase: false,
})

View File

@@ -28,7 +28,7 @@ const { isMetadataFile } = require('./_backupType.js')
const { isValidXva } = require('./_isValidXva.js')
const { listPartitions, LVM_PARTITION_TYPE } = require('./_listPartitions.js')
const { lvs, pvs } = require('./_lvm.js')
const { watchStreamSize } = require('./_watchStreamSize')
const { watchStreamSize } = require('./_watchStreamSize.js')
// @todo : this import is marked extraneous , sould be fixed when lib is published
const { mount } = require('@vates/fuse-vhd')
const { asyncEach } = require('@vates/async-each')

View File

@@ -1,7 +1,7 @@
'use strict'
const { DIR_XO_POOL_METADATA_BACKUPS } = require('./RemoteAdapter.js')
const { PATH_DB_DUMP } = require('./_PoolMetadataBackup.js')
const { PATH_DB_DUMP } = require('./_backupJob/PoolMetadataBackup.js')
exports.RestoreMetadataBackup = class RestoreMetadataBackup {
constructor({ backupId, handler, xapi }) {

View File

@@ -1,515 +0,0 @@
'use strict'
const assert = require('assert')
const findLast = require('lodash/findLast.js')
const groupBy = require('lodash/groupBy.js')
const ignoreErrors = require('promise-toolbox/ignoreErrors')
const keyBy = require('lodash/keyBy.js')
const mapValues = require('lodash/mapValues.js')
const vhdStreamValidator = require('vhd-lib/vhdStreamValidator.js')
const { asyncMap } = require('@xen-orchestra/async-map')
const { createLogger } = require('@xen-orchestra/log')
const { decorateMethodsWith } = require('@vates/decorate-with')
const { defer } = require('golike-defer')
const { formatDateTime } = require('@xen-orchestra/xapi')
const { pipeline } = require('node:stream')
const { DeltaBackupWriter } = require('./writers/DeltaBackupWriter.js')
const { DeltaReplicationWriter } = require('./writers/DeltaReplicationWriter.js')
const { exportDeltaVm } = require('./_deltaVm.js')
const { forkStreamUnpipe } = require('./_forkStreamUnpipe.js')
const { FullBackupWriter } = require('./writers/FullBackupWriter.js')
const { FullReplicationWriter } = require('./writers/FullReplicationWriter.js')
const { getOldEntries } = require('./_getOldEntries.js')
const { Task } = require('./Task.js')
const { watchStreamSize } = require('./_watchStreamSize.js')
const { debug, warn } = createLogger('xo:backups:VmBackup')
class AggregateError extends Error {
constructor(errors, message) {
super(message)
this.errors = errors
}
}
const asyncEach = async (iterable, fn, thisArg = iterable) => {
for (const item of iterable) {
await fn.call(thisArg, item)
}
}
const forkDeltaExport = deltaExport =>
Object.create(deltaExport, {
streams: {
value: mapValues(deltaExport.streams, forkStreamUnpipe),
},
})
const noop = Function.prototype
class VmBackup {
constructor({
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
remotes,
schedule,
settings,
srs,
throttleStream,
vm,
}) {
if (vm.other_config['xo:backup:job'] === job.id && 'start' in vm.blocked_operations) {
// don't match replicated VMs created by this very job otherwise they
// will be replicated again and again
throw new Error('cannot backup a VM created by this very job')
}
this.config = config
this.job = job
this.remoteAdapters = remoteAdapters
this.scheduleId = schedule.id
this.timestamp = undefined
// VM currently backed up
this.vm = vm
const { tags } = this.vm
// VM (snapshot) that is really exported
this.exportedVm = undefined
this._fullVdisRequired = undefined
this._getSnapshotNameLabel = getSnapshotNameLabel
this._isDelta = job.mode === 'delta'
this._healthCheckSr = healthCheckSr
this._jobId = job.id
this._jobSnapshots = undefined
this._throttleStream = throttleStream
this._xapi = vm.$xapi
// Base VM for the export
this._baseVm = undefined
// Settings for this specific run (job, schedule, VM)
if (tags.includes('xo-memory-backup')) {
settings.checkpointSnapshot = true
}
if (tags.includes('xo-offline-backup')) {
settings.offlineSnapshot = true
}
this._settings = settings
// Create writers
{
const writers = new Set()
this._writers = writers
const [BackupWriter, ReplicationWriter] = this._isDelta
? [DeltaBackupWriter, DeltaReplicationWriter]
: [FullBackupWriter, FullReplicationWriter]
const allSettings = job.settings
Object.keys(remoteAdapters).forEach(remoteId => {
const targetSettings = {
...settings,
...allSettings[remoteId],
}
if (targetSettings.exportRetention !== 0) {
writers.add(new BackupWriter({ backup: this, remoteId, settings: targetSettings }))
}
})
srs.forEach(sr => {
const targetSettings = {
...settings,
...allSettings[sr.uuid],
}
if (targetSettings.copyRetention !== 0) {
writers.add(new ReplicationWriter({ backup: this, sr, settings: targetSettings }))
}
})
}
}
// calls fn for each function, warns of any errors, and throws only if there are no writers left
async _callWriters(fn, step, parallel = true) {
const writers = this._writers
const n = writers.size
if (n === 0) {
return
}
async function callWriter(writer) {
const { name } = writer.constructor
try {
debug('writer step starting', { step, writer: name })
await fn(writer)
debug('writer step succeeded', { duration: step, writer: name })
} catch (error) {
writers.delete(writer)
warn('writer step failed', { error, step, writer: name })
// these two steps are the only one that are not already in their own sub tasks
if (step === 'writer.checkBaseVdis()' || step === 'writer.beforeBackup()') {
Task.warning(
`the writer ${name} has failed the step ${step} with error ${error.message}. It won't be used anymore in this job execution.`
)
}
throw error
}
}
if (n === 1) {
const [writer] = writers
return callWriter(writer)
}
const errors = []
await (parallel ? asyncMap : asyncEach)(writers, async function (writer) {
try {
await callWriter(writer)
} catch (error) {
errors.push(error)
}
})
if (writers.size === 0) {
throw new AggregateError(errors, 'all targets have failed, step: ' + step)
}
}
// ensure the VM itself does not have any backup metadata which would be
// copied on manual snapshots and interfere with the backup jobs
async _cleanMetadata() {
const { vm } = this
if ('xo:backup:job' in vm.other_config) {
await vm.update_other_config({
'xo:backup:datetime': null,
'xo:backup:deltaChainLength': null,
'xo:backup:exported': null,
'xo:backup:job': null,
'xo:backup:schedule': null,
'xo:backup:vm': null,
})
}
}
async _snapshot() {
const { vm } = this
const xapi = this._xapi
const settings = this._settings
const doSnapshot =
settings.unconditionalSnapshot ||
this._isDelta ||
(!settings.offlineBackup && vm.power_state === 'Running') ||
settings.snapshotRetention !== 0
if (doSnapshot) {
await Task.run({ name: 'snapshot' }, async () => {
if (!settings.bypassVdiChainsCheck) {
await vm.$assertHealthyVdiChains()
}
const snapshotRef = await vm[settings.checkpointSnapshot ? '$checkpoint' : '$snapshot']({
ignoreNobakVdis: true,
name_label: this._getSnapshotNameLabel(vm),
unplugVusbs: true,
})
this.timestamp = Date.now()
await xapi.setFieldEntries('VM', snapshotRef, 'other_config', {
'xo:backup:datetime': formatDateTime(this.timestamp),
'xo:backup:job': this._jobId,
'xo:backup:schedule': this.scheduleId,
'xo:backup:vm': vm.uuid,
})
this.exportedVm = await xapi.getRecord('VM', snapshotRef)
return this.exportedVm.uuid
})
} else {
this.exportedVm = vm
this.timestamp = Date.now()
}
}
async _copyDelta() {
const { exportedVm } = this
const baseVm = this._baseVm
const fullVdisRequired = this._fullVdisRequired
const isFull = fullVdisRequired === undefined || fullVdisRequired.size !== 0
await this._callWriters(writer => writer.prepare({ isFull }), 'writer.prepare()')
const deltaExport = await exportDeltaVm(exportedVm, baseVm, {
fullVdisRequired,
})
// 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')
}
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),
sizeContainers,
timestamp,
}),
'writer.transfer()'
)
this._baseVm = exportedVm
if (baseVm !== undefined) {
await exportedVm.update_other_config(
'xo:backup:deltaChainLength',
String(+(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1)
)
}
// not the case if offlineBackup
if (exportedVm.is_a_snapshot) {
await exportedVm.update_other_config('xo:backup:exported', 'true')
}
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()')
}
async _copyFull() {
const { compression } = this.job
const stream = this._throttleStream(
await this._xapi.VM_export(this.exportedVm.$ref, {
compress: Boolean(compression) && (compression === 'native' ? 'gzip' : 'zstd'),
useSnapshot: false,
})
)
const sizeContainer = watchStreamSize(stream)
const timestamp = Date.now()
await this._callWriters(
writer =>
writer.run({
sizeContainer,
stream: forkStreamUnpipe(stream),
timestamp,
}),
'writer.run()'
)
const { size } = sizeContainer
const end = Date.now()
const duration = end - timestamp
debug('transfer complete', {
duration,
speed: duration !== 0 ? (size * 1e3) / 1024 / 1024 / duration : 0,
size,
})
}
async _fetchJobSnapshots() {
const jobId = this._jobId
const vmRef = this.vm.$ref
const xapi = this._xapi
const snapshotsRef = await xapi.getField('VM', vmRef, 'snapshots')
const snapshotsOtherConfig = await asyncMap(snapshotsRef, ref => xapi.getField('VM', ref, 'other_config'))
const snapshots = []
snapshotsOtherConfig.forEach((other_config, i) => {
if (other_config['xo:backup:job'] === jobId) {
snapshots.push({ other_config, $ref: snapshotsRef[i] })
}
})
snapshots.sort((a, b) => (a.other_config['xo:backup:datetime'] < b.other_config['xo:backup:datetime'] ? -1 : 1))
this._jobSnapshots = snapshots
}
async _removeUnusedSnapshots() {
const allSettings = this.job.settings
const baseSettings = this._baseSettings
const baseVmRef = this._baseVm?.$ref
const snapshotsPerSchedule = groupBy(this._jobSnapshots, _ => _.other_config['xo:backup:schedule'])
const xapi = this._xapi
await asyncMap(Object.entries(snapshotsPerSchedule), ([scheduleId, snapshots]) => {
const settings = {
...baseSettings,
...allSettings[scheduleId],
...allSettings[this.vm.uuid],
}
return asyncMap(getOldEntries(settings.snapshotRetention, snapshots), ({ $ref }) => {
if ($ref !== baseVmRef) {
return xapi.VM_destroy($ref)
}
})
})
}
async _selectBaseVm() {
const xapi = this._xapi
let baseVm = findLast(this._jobSnapshots, _ => 'xo:backup:exported' in _.other_config)
if (baseVm === undefined) {
debug('no base VM found')
return
}
const fullInterval = this._settings.fullInterval
const deltaChainLength = +(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1
if (!(fullInterval === 0 || fullInterval > deltaChainLength)) {
debug('not using base VM becaust fullInterval reached')
return
}
const srcVdis = keyBy(await xapi.getRecords('VDI', await this.vm.$getDisks()), '$ref')
// resolve full record
baseVm = await xapi.getRecord('VM', baseVm.$ref)
const baseUuidToSrcVdi = new Map()
await asyncMap(await baseVm.$getDisks(), async baseRef => {
const [baseUuid, snapshotOf] = await Promise.all([
xapi.getField('VDI', baseRef, 'uuid'),
xapi.getField('VDI', baseRef, 'snapshot_of'),
])
const srcVdi = srcVdis[snapshotOf]
if (srcVdi !== undefined) {
baseUuidToSrcVdi.set(baseUuid, srcVdi)
} else {
debug('ignore snapshot VDI because no longer present on VM', {
vdi: baseUuid,
})
}
})
const presentBaseVdis = new Map(baseUuidToSrcVdi)
await this._callWriters(
writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis, baseVm),
'writer.checkBaseVdis()',
false
)
if (presentBaseVdis.size === 0) {
debug('no base VM found')
return
}
const fullVdisRequired = new Set()
baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
if (presentBaseVdis.has(baseUuid)) {
debug('found base VDI', {
base: baseUuid,
vdi: srcVdi.uuid,
})
} else {
debug('missing base VDI', {
base: baseUuid,
vdi: srcVdi.uuid,
})
fullVdisRequired.add(srcVdi.uuid)
}
})
this._baseVm = baseVm
this._fullVdisRequired = fullVdisRequired
}
async _healthCheck() {
const settings = this._settings
if (this._healthCheckSr === undefined) {
return
}
// check if current VM has tags
const { tags } = this.vm
const intersect = settings.healthCheckVmsWithTags.some(t => tags.includes(t))
if (settings.healthCheckVmsWithTags.length !== 0 && !intersect) {
return
}
await this._callWriters(writer => writer.healthCheck(this._healthCheckSr), 'writer.healthCheck()')
}
async run($defer) {
const settings = this._settings
assert(
!settings.offlineBackup || settings.snapshotRetention === 0,
'offlineBackup is not compatible with snapshotRetention'
)
await this._callWriters(async writer => {
await writer.beforeBackup()
$defer(async () => {
await writer.afterBackup()
})
}, 'writer.beforeBackup()')
await this._fetchJobSnapshots()
if (this._isDelta) {
await this._selectBaseVm()
}
await this._cleanMetadata()
await this._removeUnusedSnapshots()
const { vm } = this
const isRunning = vm.power_state === 'Running'
const startAfter = isRunning && (settings.offlineBackup ? 'backup' : settings.offlineSnapshot && 'snapshot')
if (startAfter) {
await vm.$callAsync('clean_shutdown')
}
try {
await this._snapshot()
if (startAfter === 'snapshot') {
ignoreErrors.call(vm.$callAsync('start', false, false))
}
if (this._writers.size !== 0) {
await (this._isDelta ? this._copyDelta() : this._copyFull())
}
} finally {
if (startAfter) {
ignoreErrors.call(vm.$callAsync('start', false, false))
}
await this._fetchJobSnapshots()
await this._removeUnusedSnapshots()
}
await this._healthCheck()
}
}
exports.VmBackup = VmBackup
decorateMethodsWith(VmBackup, {
run: defer,
})

View File

@@ -0,0 +1,51 @@
'use strict'
const Disposable = require('promise-toolbox/Disposable')
const pTimeout = require('promise-toolbox/timeout')
const { compileTemplate } = require('@xen-orchestra/template')
const { runTask } = require('./runTask.js')
const { RemoteTimeoutError } = require('./RemoteTimeoutError.js')
exports.DEFAULT_SETTINGS = {
getRemoteTimeout: 300e3,
reportWhen: 'failure',
}
exports.AbstractBackupJob = class AbstractBackupJob {
constructor({ config, getAdapter, getConnectedRecord, job, schedule }) {
this._config = config
this._getRecord = getConnectedRecord
this._job = job
this._schedule = schedule
this._getSnapshotNameLabel = compileTemplate(config.snapshotNameLabelTpl, {
'{job.name}': job.name,
'{vm.name_label}': vm => vm.name_label,
})
const baseSettings = this._computeBaseSettings(config, job)
this._baseSettings = baseSettings
this._settings = { ...baseSettings, ...job.settings[schedule.id] }
const { getRemoteTimeout } = this._settings
this._getAdapter = async function (remoteId) {
try {
const disposable = await pTimeout.call(getAdapter(remoteId), getRemoteTimeout, new RemoteTimeoutError(remoteId))
return new Disposable(() => disposable.dispose(), {
adapter: disposable.value,
remoteId,
})
} catch (error) {
// See https://github.com/vatesfr/xen-orchestra/commit/6aa6cfba8ec939c0288f0fa740f6dfad98c43cbb
runTask(
{
name: 'get remote adapter',
data: { type: 'remote', id: remoteId },
},
() => Promise.reject(error)
)
}
}
}
}

View File

@@ -0,0 +1,134 @@
'use strict'
const { asyncMap } = require('@xen-orchestra/async-map')
const Disposable = require('promise-toolbox/Disposable')
const ignoreErrors = require('promise-toolbox/ignoreErrors')
const { extractIdsFromSimplePattern } = require('../extractIdsFromSimplePattern.js')
const { PoolMetadataBackup } = require('./PoolMetadataBackup.js')
const { XoMetadataBackup } = require('./XoMetadataBackup.js')
const { DEFAULT_SETTINGS, AbstractBackupJob } = require('./AbstractBackupJob.js')
const { runTask } = require('./runTask.js')
const { getAdaptersByRemote } = require('./getAdaptersByRemote.js')
const DEFAULT_METADATA_SETTINGS = {
retentionPoolMetadata: 0,
retentionXoMetadata: 0,
}
exports.MetadatasBackupJob = class MetadatasBackupJob extends AbstractBackupJob {
_computeBaseSettings(config, job) {
const baseSettings = { ...DEFAULT_SETTINGS }
Object.assign(baseSettings, DEFAULT_METADATA_SETTINGS, config.defaultSettings, config.metadata?.defaultSettings)
Object.assign(baseSettings, job.settings[''])
return baseSettings
}
async run() {
const schedule = this._schedule
const job = this._job
const remoteIds = extractIdsFromSimplePattern(job.remotes)
if (remoteIds.length === 0) {
throw new Error('metadata backup job cannot run without remotes')
}
const config = this._config
const poolIds = extractIdsFromSimplePattern(job.pools)
const isEmptyPools = poolIds.length === 0
const isXoMetadata = job.xoMetadata !== undefined
if (!isXoMetadata && isEmptyPools) {
throw new Error('no metadata mode found')
}
const settings = this._settings
const { retentionPoolMetadata, retentionXoMetadata } = settings
if (
(retentionPoolMetadata === 0 && retentionXoMetadata === 0) ||
(!isXoMetadata && retentionPoolMetadata === 0) ||
(isEmptyPools && retentionXoMetadata === 0)
) {
throw new Error('no retentions corresponding to the metadata modes found')
}
await Disposable.use(
Disposable.all(
poolIds.map(id =>
this._getRecord('pool', id).catch(error => {
// See https://github.com/vatesfr/xen-orchestra/commit/6aa6cfba8ec939c0288f0fa740f6dfad98c43cbb
runTask(
{
name: 'get pool record',
data: { type: 'pool', id },
},
() => Promise.reject(error)
)
})
)
),
Disposable.all(remoteIds.map(id => this._getAdapter(id))),
async (pools, remoteAdapters) => {
// remove adapters that failed (already handled)
remoteAdapters = remoteAdapters.filter(_ => _ !== undefined)
if (remoteAdapters.length === 0) {
return
}
remoteAdapters = getAdaptersByRemote(remoteAdapters)
// remove pools that failed (already handled)
pools = pools.filter(_ => _ !== undefined)
const promises = []
if (pools.length !== 0 && settings.retentionPoolMetadata !== 0) {
promises.push(
asyncMap(pools, async pool =>
runTask(
{
name: `Starting metadata backup for the pool (${pool.$id}). (${job.id})`,
data: {
id: pool.$id,
pool,
poolMaster: await ignoreErrors.call(pool.$xapi.getRecord('host', pool.master)),
type: 'pool',
},
},
() =>
new PoolMetadataBackup({
config,
job,
pool,
remoteAdapters,
schedule,
settings,
}).run()
)
)
)
}
if (job.xoMetadata !== undefined && settings.retentionXoMetadata !== 0) {
promises.push(
runTask(
{
name: `Starting XO metadata backup. (${job.id})`,
data: {
type: 'xo',
},
},
() =>
new XoMetadataBackup({
config,
job,
remoteAdapters,
schedule,
settings,
}).run()
)
)
}
await Promise.all(promises)
}
)
}
}

View File

@@ -2,10 +2,10 @@
const { asyncMap } = require('@xen-orchestra/async-map')
const { DIR_XO_POOL_METADATA_BACKUPS } = require('./RemoteAdapter.js')
const { forkStreamUnpipe } = require('./_forkStreamUnpipe.js')
const { formatFilenameDate } = require('./_filenameDate.js')
const { Task } = require('./Task.js')
const { DIR_XO_POOL_METADATA_BACKUPS } = require('../RemoteAdapter.js')
const { forkStreamUnpipe } = require('./forkStreamUnpipe.js')
const { formatFilenameDate } = require('../_filenameDate.js')
const { Task } = require('../Task.js')
const PATH_DB_DUMP = '/pool/xmldbdump'
exports.PATH_DB_DUMP = PATH_DB_DUMP

View File

@@ -0,0 +1,8 @@
'use strict'
class RemoteTimeoutError extends Error {
constructor(remoteId) {
super('timeout while getting the remote ' + remoteId)
this.remoteId = remoteId
}
}
exports.RemoteTimeoutError = RemoteTimeoutError

View File

@@ -0,0 +1,87 @@
'use strict'
const { asyncMap } = require('@xen-orchestra/async-map')
const { createLogger } = require('@xen-orchestra/log')
const { Task } = require('../../Task.js')
const { debug, warn } = createLogger('xo:backups:AbstractVmBackup')
class AggregateError extends Error {
constructor(errors, message) {
super(message)
this.errors = errors
}
}
const asyncEach = async (iterable, fn, thisArg = iterable) => {
for (const item of iterable) {
await fn.call(thisArg, item)
}
}
exports.AbstractVmBackup = class AbstractVmBackup {
// calls fn for each function, warns of any errors, and throws only if there are no writers left
async _callWriters(fn, step, parallel = true) {
const writers = this._writers
const n = writers.size
if (n === 0) {
return
}
async function callWriter(writer) {
const { name } = writer.constructor
try {
debug('writer step starting', { step, writer: name })
await fn(writer)
debug('writer step succeeded', { duration: step, writer: name })
} catch (error) {
writers.delete(writer)
warn('writer step failed', { error, step, writer: name })
// these two steps are the only one that are not already in their own sub tasks
if (step === 'writer.checkBaseVdis()' || step === 'writer.beforeBackup()') {
Task.warning(
`the writer ${name} has failed the step ${step} with error ${error.message}. It won't be used anymore in this job execution.`
)
}
throw error
}
}
if (n === 1) {
const [writer] = writers
return callWriter(writer)
}
const errors = []
await (parallel ? asyncMap : asyncEach)(writers, async function (writer) {
try {
await callWriter(writer)
} catch (error) {
errors.push(error)
}
})
if (writers.size === 0) {
throw new AggregateError(errors, 'all targets have failed, step: ' + step)
}
}
async _healthCheck() {
const settings = this._settings
if (this._healthCheckSr === undefined) {
return
}
// check if current VM has tags
const { tags } = this.vm
const intersect = settings.healthCheckVmsWithTags.some(t => tags.includes(t))
if (settings.healthCheckVmsWithTags.length !== 0 && !intersect) {
return
}
await this._callWriters(writer => writer.healthCheck(this._healthCheckSr), 'writer.healthCheck()')
}
}

View File

@@ -0,0 +1,258 @@
'use strict'
const assert = require('assert')
const groupBy = require('lodash/groupBy.js')
const ignoreErrors = require('promise-toolbox/ignoreErrors')
const { asyncMap } = require('@xen-orchestra/async-map')
const { decorateMethodsWith } = require('@vates/decorate-with')
const { defer } = require('golike-defer')
const { formatDateTime } = require('@xen-orchestra/xapi')
const { getOldEntries } = require('../../_getOldEntries.js')
const { Task } = require('../../Task.js')
const { AbstractVmBackup } = require('./AbstractVMBackup.js')
class AbstractXapiVmBackup extends AbstractVmBackup {
constructor({
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
remotes,
schedule,
settings,
srs,
throttleStream,
vm,
}) {
super()
if (vm.other_config['xo:backup:job'] === job.id && 'start' in vm.blocked_operations) {
// don't match replicated VMs created by this very job otherwise they
// will be replicated again and again
throw new Error('cannot backup a VM created by this very job')
}
this.config = config
this.job = job
this.remoteAdapters = remoteAdapters
this.scheduleId = schedule.id
this.timestamp = undefined
// VM currently backed up
this.vm = vm
const { tags } = this.vm
// VM (snapshot) that is really exported
this.exportedVm = undefined
this._fullVdisRequired = undefined
this._getSnapshotNameLabel = getSnapshotNameLabel
this._isIncremental = job.mode === 'delta'
this._healthCheckSr = healthCheckSr
this._jobId = job.id
this._jobSnapshots = undefined
this._throttleStream = throttleStream
this._xapi = vm.$xapi
// Base VM for the export
this._baseVm = undefined
// Settings for this specific run (job, schedule, VM)
if (tags.includes('xo-memory-backup')) {
settings.checkpointSnapshot = true
}
if (tags.includes('xo-offline-backup')) {
settings.offlineSnapshot = true
}
this._settings = settings
// Create writers
{
const writers = new Set()
this._writers = writers
const [BackupWriter, ReplicationWriter] = this._getWriters()
const allSettings = job.settings
Object.keys(remoteAdapters).forEach(remoteId => {
const targetSettings = {
...settings,
...allSettings[remoteId],
}
if (targetSettings.exportRetention !== 0) {
writers.add(new BackupWriter({ backup: this, remoteId, settings: targetSettings }))
}
})
srs.forEach(sr => {
const targetSettings = {
...settings,
...allSettings[sr.uuid],
}
if (targetSettings.copyRetention !== 0) {
writers.add(new ReplicationWriter({ backup: this, sr, settings: targetSettings }))
}
})
}
}
// ensure the VM itself does not have any backup metadata which would be
// copied on manual snapshots and interfere with the backup jobs
async _cleanMetadata() {
const { vm } = this
if ('xo:backup:job' in vm.other_config) {
await vm.update_other_config({
'xo:backup:datetime': null,
'xo:backup:deltaChainLength': null,
'xo:backup:exported': null,
'xo:backup:job': null,
'xo:backup:schedule': null,
'xo:backup:vm': null,
})
}
}
async _snapshot() {
const { vm } = this
const xapi = this._xapi
const settings = this._settings
if (this._mustDoSnapshot()) {
await Task.run({ name: 'snapshot' }, async () => {
if (!settings.bypassVdiChainsCheck) {
await vm.$assertHealthyVdiChains()
}
const snapshotRef = await vm[settings.checkpointSnapshot ? '$checkpoint' : '$snapshot']({
ignoreNobakVdis: true,
name_label: this._getSnapshotNameLabel(vm),
unplugVusbs: true,
})
this.timestamp = Date.now()
await xapi.setFieldEntries('VM', snapshotRef, 'other_config', {
'xo:backup:datetime': formatDateTime(this.timestamp),
'xo:backup:job': this._jobId,
'xo:backup:schedule': this.scheduleId,
'xo:backup:vm': vm.uuid,
})
this.exportedVm = await xapi.getRecord('VM', snapshotRef)
return this.exportedVm.uuid
})
} else {
this.exportedVm = vm
this.timestamp = Date.now()
}
}
async _fetchJobSnapshots() {
const jobId = this._jobId
const vmRef = this.vm.$ref
const xapi = this._xapi
const snapshotsRef = await xapi.getField('VM', vmRef, 'snapshots')
const snapshotsOtherConfig = await asyncMap(snapshotsRef, ref => xapi.getField('VM', ref, 'other_config'))
const snapshots = []
snapshotsOtherConfig.forEach((other_config, i) => {
if (other_config['xo:backup:job'] === jobId) {
snapshots.push({ other_config, $ref: snapshotsRef[i] })
}
})
snapshots.sort((a, b) => (a.other_config['xo:backup:datetime'] < b.other_config['xo:backup:datetime'] ? -1 : 1))
this._jobSnapshots = snapshots
}
async _removeUnusedSnapshots() {
const allSettings = this.job.settings
const baseSettings = this._baseSettings
const baseVmRef = this._baseVm?.$ref
const snapshotsPerSchedule = groupBy(this._jobSnapshots, _ => _.other_config['xo:backup:schedule'])
const xapi = this._xapi
await asyncMap(Object.entries(snapshotsPerSchedule), ([scheduleId, snapshots]) => {
const settings = {
...baseSettings,
...allSettings[scheduleId],
...allSettings[this.vm.uuid],
}
return asyncMap(getOldEntries(settings.snapshotRetention, snapshots), ({ $ref }) => {
if ($ref !== baseVmRef) {
return xapi.VM_destroy($ref)
}
})
})
}
async copy() {
throw new Error('Not implemented')
}
_getWriters() {
throw new Error('Not implemented')
}
_mustDoSnapshot() {
throw new Error('Not implemented')
}
async _selectBaseVm() {
throw new Error('Not implemented')
}
async run($defer) {
const settings = this._settings
assert(
!settings.offlineBackup || settings.snapshotRetention === 0,
'offlineBackup is not compatible with snapshotRetention'
)
await this._callWriters(async writer => {
await writer.beforeBackup()
$defer(async () => {
await writer.afterBackup()
})
}, 'writer.beforeBackup()')
await this._fetchJobSnapshots()
await this._selectBaseVm()
await this._cleanMetadata()
await this._removeUnusedSnapshots()
const { vm } = this
const isRunning = vm.power_state === 'Running'
const startAfter = isRunning && (settings.offlineBackup ? 'backup' : settings.offlineSnapshot && 'snapshot')
if (startAfter) {
await vm.$callAsync('clean_shutdown')
}
try {
await this._snapshot()
if (startAfter === 'snapshot') {
ignoreErrors.call(vm.$callAsync('start', false, false))
}
if (this._writers.size !== 0) {
await this._copy()
}
} finally {
if (startAfter) {
ignoreErrors.call(vm.$callAsync('start', false, false))
}
await this._fetchJobSnapshots()
await this._removeUnusedSnapshots()
}
await this._healthCheck()
}
}
exports.AbstractXapiVmBackup = AbstractXapiVmBackup
decorateMethodsWith(AbstractXapiVmBackup, {
run: defer,
})

View File

@@ -0,0 +1,61 @@
'use strict'
const { createLogger } = require('@xen-orchestra/log')
const { forkStreamUnpipe } = require('../forkStreamUnpipe.js')
const { FullRemoteWriter } = require('./writers/FullRemoteWriter.js')
const { FullXapiWriter } = require('./writers/FullXapiWriter.js')
const { watchStreamSize } = require('../../_watchStreamSize.js')
const { AbstractXapiVmBackup } = require('./AbstractXapiVmBackup.js')
const { debug } = createLogger('xo:backups:FullXapiVmBackup')
exports.FullXapiVmBackup = class FullXapiVmBackup extends AbstractXapiVmBackup {
_getWriters() {
return [FullRemoteWriter, FullXapiWriter]
}
_mustDoSnapshot() {
const { vm } = this
const settings = this._settings
return (
settings.unconditionalSnapshot ||
(!settings.offlineBackup && vm.power_state === 'Running') ||
settings.snapshotRetention !== 0
)
}
_selectBaseVm() {}
async _copy() {
const { compression } = this.job
const stream = this._throttleStream(
await this._xapi.VM_export(this.exportedVm.$ref, {
compress: Boolean(compression) && (compression === 'native' ? 'gzip' : 'zstd'),
useSnapshot: false,
})
)
const sizeContainer = watchStreamSize(stream)
const timestamp = Date.now()
await this._callWriters(
writer =>
writer.run({
sizeContainer,
stream: forkStreamUnpipe(stream),
timestamp,
}),
'writer.run()'
)
const { size } = sizeContainer
const end = Date.now()
const duration = end - timestamp
debug('transfer complete', {
duration,
speed: duration !== 0 ? (size * 1e3) / 1024 / 1024 / duration : 0,
size,
})
}
}

View File

@@ -0,0 +1,170 @@
'use strict'
const findLast = require('lodash/findLast.js')
const keyBy = require('lodash/keyBy.js')
const mapValues = require('lodash/mapValues.js')
const vhdStreamValidator = require('vhd-lib/vhdStreamValidator.js')
const { asyncMap } = require('@xen-orchestra/async-map')
const { createLogger } = require('@xen-orchestra/log')
const { pipeline } = require('node:stream')
const { IncrementalRemoteWriter } = require('./writers/IncrementalRemoteWriter.js')
const { IncrementalXapiWriter } = require('./writers/IncrementalXapiWriter.js')
const { exportIncrementalVm } = require('../../_incrementalVm.js')
const { forkStreamUnpipe } = require('../forkStreamUnpipe.js')
const { Task } = require('../../Task.js')
const { watchStreamSize } = require('../../_watchStreamSize.js')
const { AbstractXapiVmBackup } = require('./AbstractXapiVmBackup.js')
const { debug } = createLogger('xo:backups:IncrementalXapiVmBackup')
const forkDeltaExport = deltaExport =>
Object.create(deltaExport, {
streams: {
value: mapValues(deltaExport.streams, forkStreamUnpipe),
},
})
const noop = Function.prototype
exports.IncrementalXapiVmBackup = class IncrementalXapiVmBackup extends AbstractXapiVmBackup {
_getWriters() {
return [IncrementalRemoteWriter, IncrementalXapiWriter]
}
_mustDoSnapshot() {
return true
}
async _copy() {
const { exportedVm } = this
const baseVm = this._baseVm
const fullVdisRequired = this._fullVdisRequired
const isFull = fullVdisRequired === undefined || fullVdisRequired.size !== 0
await this._callWriters(writer => writer.prepare({ isFull }), 'writer.prepare()')
const deltaExport = await exportIncrementalVm(exportedVm, baseVm, {
fullVdisRequired,
})
// 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')
}
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),
sizeContainers,
timestamp,
}),
'writer.transfer()'
)
this._baseVm = exportedVm
if (baseVm !== undefined) {
await exportedVm.update_other_config(
'xo:backup:deltaChainLength',
String(+(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1)
)
}
// not the case if offlineBackup
if (exportedVm.is_a_snapshot) {
await exportedVm.update_other_config('xo:backup:exported', 'true')
}
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()')
}
async _selectBaseVm() {
const xapi = this._xapi
let baseVm = findLast(this._jobSnapshots, _ => 'xo:backup:exported' in _.other_config)
if (baseVm === undefined) {
debug('no base VM found')
return
}
const fullInterval = this._settings.fullInterval
const deltaChainLength = +(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1
if (!(fullInterval === 0 || fullInterval > deltaChainLength)) {
debug('not using base VM becaust fullInterval reached')
return
}
const srcVdis = keyBy(await xapi.getRecords('VDI', await this.vm.$getDisks()), '$ref')
// resolve full record
baseVm = await xapi.getRecord('VM', baseVm.$ref)
const baseUuidToSrcVdi = new Map()
await asyncMap(await baseVm.$getDisks(), async baseRef => {
const [baseUuid, snapshotOf] = await Promise.all([
xapi.getField('VDI', baseRef, 'uuid'),
xapi.getField('VDI', baseRef, 'snapshot_of'),
])
const srcVdi = srcVdis[snapshotOf]
if (srcVdi !== undefined) {
baseUuidToSrcVdi.set(baseUuid, srcVdi)
} else {
debug('ignore snapshot VDI because no longer present on VM', {
vdi: baseUuid,
})
}
})
const presentBaseVdis = new Map(baseUuidToSrcVdi)
await this._callWriters(
writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis, baseVm),
'writer.checkBaseVdis()',
false
)
if (presentBaseVdis.size === 0) {
debug('no base VM found')
return
}
const fullVdisRequired = new Set()
baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
if (presentBaseVdis.has(baseUuid)) {
debug('found base VDI', {
base: baseUuid,
vdi: srcVdi.uuid,
})
} else {
debug('missing base VDI', {
base: baseUuid,
vdi: srcVdi.uuid,
})
fullVdisRequired.add(srcVdi.uuid)
}
})
this._baseVm = baseVm
this._fullVdisRequired = fullVdisRequired
}
}

View File

@@ -1,13 +1,13 @@
'use strict'
const { formatFilenameDate } = require('../_filenameDate.js')
const { getOldEntries } = require('../_getOldEntries.js')
const { Task } = require('../Task.js')
const { formatFilenameDate } = require('../../../_filenameDate.js')
const { getOldEntries } = require('../../../_getOldEntries.js')
const { Task } = require('../../../Task.js')
const { MixinBackupWriter } = require('./_MixinBackupWriter.js')
const { MixinRemoteWriter } = require('./_MixinRemoteWriter.js')
const { AbstractFullWriter } = require('./_AbstractFullWriter.js')
exports.FullBackupWriter = class FullBackupWriter extends MixinBackupWriter(AbstractFullWriter) {
exports.FullRemoteWriter = class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
constructor(props) {
super(props)

View File

@@ -4,15 +4,15 @@ const ignoreErrors = require('promise-toolbox/ignoreErrors')
const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
const { formatDateTime } = require('@xen-orchestra/xapi')
const { formatFilenameDate } = require('../_filenameDate.js')
const { getOldEntries } = require('../_getOldEntries.js')
const { Task } = require('../Task.js')
const { formatFilenameDate } = require('../../../_filenameDate.js')
const { getOldEntries } = require('../../../_getOldEntries.js')
const { Task } = require('../../../Task.js')
const { AbstractFullWriter } = require('./_AbstractFullWriter.js')
const { MixinReplicationWriter } = require('./_MixinReplicationWriter.js')
const { MixinXapiWriter } = require('./_MixinXapiWriter.js')
const { listReplicatedVms } = require('./_listReplicatedVms.js')
exports.FullReplicationWriter = class FullReplicationWriter extends MixinReplicationWriter(AbstractFullWriter) {
exports.FullXapiWriter = class FullXapiWriter extends MixinXapiWriter(AbstractFullWriter) {
constructor(props) {
super(props)

View File

@@ -11,19 +11,19 @@ const { decorateClass } = require('@vates/decorate-with')
const { defer } = require('golike-defer')
const { dirname } = require('path')
const { formatFilenameDate } = require('../_filenameDate.js')
const { getOldEntries } = require('../_getOldEntries.js')
const { Task } = require('../Task.js')
const { formatFilenameDate } = require('../../../_filenameDate.js')
const { getOldEntries } = require('../../../_getOldEntries.js')
const { Task } = require('../../../Task.js')
const { MixinBackupWriter } = require('./_MixinBackupWriter.js')
const { AbstractDeltaWriter } = require('./_AbstractDeltaWriter.js')
const { MixinRemoteWriter } = require('./_MixinRemoteWriter.js')
const { AbstractIncrementalWriter } = require('./_AbstractIncrementalWriter.js')
const { checkVhd } = require('./_checkVhd.js')
const { packUuid } = require('./_packUuid.js')
const { Disposable } = require('promise-toolbox')
const { warn } = createLogger('xo:backups:DeltaBackupWriter')
class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrementalWriter) {
async checkBaseVdis(baseUuidToSrcVdi) {
const { handler } = this._adapter
const backup = this._backup
@@ -227,6 +227,6 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
// TODO: run cleanup?
}
}
exports.DeltaBackupWriter = decorateClass(DeltaBackupWriter, {
exports.IncrementalRemoteWriter = decorateClass(IncrementalRemoteWriter, {
_transfer: defer,
})

View File

@@ -4,16 +4,16 @@ const { asyncMap, asyncMapSettled } = require('@xen-orchestra/async-map')
const ignoreErrors = require('promise-toolbox/ignoreErrors')
const { formatDateTime } = require('@xen-orchestra/xapi')
const { formatFilenameDate } = require('../_filenameDate.js')
const { getOldEntries } = require('../_getOldEntries.js')
const { importDeltaVm, TAG_COPY_SRC } = require('../_deltaVm.js')
const { Task } = require('../Task.js')
const { formatFilenameDate } = require('../../../_filenameDate.js')
const { getOldEntries } = require('../../../_getOldEntries.js')
const { importIncrementalVm, TAG_COPY_SRC } = require('../../../_incrementalVm.js')
const { Task } = require('../../../Task.js')
const { AbstractDeltaWriter } = require('./_AbstractDeltaWriter.js')
const { MixinReplicationWriter } = require('./_MixinReplicationWriter.js')
const { AbstractIncrementalWriter } = require('./_AbstractIncrementalWriter.js')
const { MixinXapiWriter } = require('./_MixinXapiWriter.js')
const { listReplicatedVms } = require('./_listReplicatedVms.js')
exports.DeltaReplicationWriter = class DeltaReplicationWriter extends MixinReplicationWriter(AbstractDeltaWriter) {
exports.IncrementalXapiWriter = class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
async checkBaseVdis(baseUuidToSrcVdi, baseVm) {
const sr = this._sr
const replicatedVm = listReplicatedVms(sr.$xapi, this._backup.job.id, sr.uuid, this._backup.vm.uuid).find(
@@ -50,8 +50,8 @@ exports.DeltaReplicationWriter = class DeltaReplicationWriter extends MixinRepli
},
})
this.transfer = task.wrapFn(this.transfer)
this.healthCheck = task.wrapFn(this.healthCheck)
this.cleanup = task.wrapFn(this.cleanup, true)
this.cleanup = task.wrapFn(this.cleanup)
this.healthCheck = task.wrapFn(this.healthCheck, true)
return task.run(() => this._prepare())
}
@@ -90,7 +90,7 @@ exports.DeltaReplicationWriter = class DeltaReplicationWriter extends MixinRepli
let targetVmRef
await Task.run({ name: 'transfer' }, async () => {
targetVmRef = await importDeltaVm(
targetVmRef = await importIncrementalVm(
{
__proto__: deltaExport,
vm: {

View File

@@ -2,7 +2,7 @@
const { AbstractWriter } = require('./_AbstractWriter.js')
exports.AbstractDeltaWriter = class AbstractDeltaWriter extends AbstractWriter {
exports.AbstractIncrementalWriter = class AbstractIncrementalWriter extends AbstractWriter {
checkBaseVdis(baseUuidToSrcVdi, baseVm) {
throw new Error('Not implemented')
}

View File

@@ -4,17 +4,17 @@ const { createLogger } = require('@xen-orchestra/log')
const { join } = require('path')
const assert = require('assert')
const { formatFilenameDate } = require('../_filenameDate.js')
const { getVmBackupDir } = require('../_getVmBackupDir.js')
const { HealthCheckVmBackup } = require('../HealthCheckVmBackup.js')
const { ImportVmBackup } = require('../ImportVmBackup.js')
const { Task } = require('../Task.js')
const MergeWorker = require('../merge-worker/index.js')
const { formatFilenameDate } = require('../../../_filenameDate.js')
const { getVmBackupDir } = require('../../../_getVmBackupDir.js')
const { HealthCheckVmBackup } = require('../../../HealthCheckVmBackup.js')
const { ImportVmBackup } = require('../../../ImportVmBackup.js')
const { Task } = require('../../../Task.js')
const MergeWorker = require('../../../merge-worker/index.js')
const { info, warn } = createLogger('xo:backups:MixinBackupWriter')
exports.MixinBackupWriter = (BaseClass = Object) =>
class MixinBackupWriter extends BaseClass {
exports.MixinRemoteWriter = (BaseClass = Object) =>
class MixinRemoteWriter extends BaseClass {
#lock
constructor({ remoteId, ...rest }) {

View File

@@ -1,8 +1,8 @@
'use strict'
const { Task } = require('../Task')
const { Task } = require('../../../Task')
const assert = require('node:assert/strict')
const { HealthCheckVmBackup } = require('../HealthCheckVmBackup')
const { HealthCheckVmBackup } = require('../../../HealthCheckVmBackup')
function extractOpaqueRef(str) {
const OPAQUE_REF_RE = /OpaqueRef:[0-9a-z-]+/
@@ -12,8 +12,8 @@ function extractOpaqueRef(str) {
}
return matches[0]
}
exports.MixinReplicationWriter = (BaseClass = Object) =>
class MixinReplicationWriter extends BaseClass {
exports.MixinXapiWriter = (BaseClass = Object) =>
class MixinXapiWriter extends BaseClass {
constructor({ sr, ...rest }) {
super(rest)

View File

@@ -0,0 +1,138 @@
'use strict'
const { asyncMapSettled } = require('@xen-orchestra/async-map')
const Disposable = require('promise-toolbox/Disposable')
const { limitConcurrency } = require('limit-concurrency-decorator')
const { extractIdsFromSimplePattern } = require('../extractIdsFromSimplePattern.js')
const { Task } = require('../Task.js')
const { createStreamThrottle } = require('./createStreamThrottle.js')
const { DEFAULT_SETTINGS, AbstractBackupJob } = require('./AbstractBackupJob.js')
const { runTask } = require('./runTask.js')
const { getAdaptersByRemote } = require('./getAdaptersByRemote.js')
const { IncrementalXapiVmBackup } = require('./VmBackup/IncrementalXapiVmBackup.js')
const { FullXapiVmBackup } = require('./VmBackup/FullXapiVmBackup.js')
const DEFAULT_XAPI_VM_SETTINGS = {
bypassVdiChainsCheck: false,
checkpointSnapshot: false,
concurrency: 2,
copyRetention: 0,
deleteFirst: false,
exportRetention: 0,
fullInterval: 0,
healthCheckSr: undefined,
healthCheckVmsWithTags: [],
maxExportRate: 0,
maxMergedDeltasPerRun: Infinity,
offlineBackup: false,
offlineSnapshot: false,
snapshotRetention: 0,
timeout: 0,
useNbd: false,
unconditionalSnapshot: false,
validateVhdStreams: false,
vmTimeout: 0,
}
exports.XapiVmBackupJob = class XapiVmBackupJob extends AbstractBackupJob {
_computeBaseSettings(config, job) {
const baseSettings = { ...DEFAULT_SETTINGS }
Object.assign(baseSettings, DEFAULT_XAPI_VM_SETTINGS, config.defaultSettings, config.vm?.defaultSettings)
Object.assign(baseSettings, job.settings[''])
return baseSettings
}
async run() {
const job = this._job
// FIXME: proper SimpleIdPattern handling
const getSnapshotNameLabel = this._getSnapshotNameLabel
const schedule = this._schedule
const settings = this._settings
const throttleStream = createStreamThrottle(settings.maxExportRate)
const config = this._config
await Disposable.use(
Disposable.all(
extractIdsFromSimplePattern(job.srs).map(id =>
this._getRecord('SR', id).catch(error => {
runTask(
{
name: 'get SR record',
data: { type: 'SR', id },
},
() => Promise.reject(error)
)
})
)
),
Disposable.all(extractIdsFromSimplePattern(job.remotes).map(id => this._getAdapter(id))),
() => (settings.healthCheckSr !== undefined ? this._getRecord('SR', settings.healthCheckSr) : undefined),
async (srs, remoteAdapters, healthCheckSr) => {
// remove adapters that failed (already handled)
remoteAdapters = remoteAdapters.filter(_ => _ !== undefined)
// remove srs that failed (already handled)
srs = srs.filter(_ => _ !== undefined)
if (remoteAdapters.length === 0 && srs.length === 0 && settings.snapshotRetention === 0) {
return
}
const vmIds = extractIdsFromSimplePattern(job.vms)
Task.info('vms', { vms: vmIds })
remoteAdapters = getAdaptersByRemote(remoteAdapters)
const allSettings = this._job.settings
const baseSettings = this._baseSettings
const handleVm = vmUuid => {
const taskStart = { name: 'backup VM', data: { type: 'VM', id: vmUuid } }
return this._getRecord('VM', vmUuid).then(
disposableVm =>
Disposable.use(disposableVm, vm => {
taskStart.data.name_label = vm.name_label
return runTask(taskStart, () => {
const opts = {
baseSettings,
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
schedule,
settings: { ...settings, ...allSettings[vm.uuid] },
srs,
throttleStream,
vm,
}
let vmBackup
if (job.mode === 'delta') {
vmBackup = new IncrementalXapiVmBackup(opts)
} else {
if (job.mode === 'full') {
vmBackup = new FullXapiVmBackup(opts)
} else {
throw new Error(`Job mode ${job.mode} not implemented`)
}
}
return vmBackup.run()
})
}),
error =>
runTask(taskStart, () => {
throw error
})
)
}
const { concurrency } = settings
await asyncMapSettled(vmIds, concurrency === 0 ? handleVm : limitConcurrency(concurrency)(handleVm))
}
)
}
}

View File

@@ -2,9 +2,9 @@
const { asyncMap } = require('@xen-orchestra/async-map')
const { DIR_XO_CONFIG_BACKUPS } = require('./RemoteAdapter.js')
const { formatFilenameDate } = require('./_filenameDate.js')
const { Task } = require('./Task.js')
const { DIR_XO_CONFIG_BACKUPS } = require('../RemoteAdapter.js')
const { formatFilenameDate } = require('../_filenameDate.js')
const { Task } = require('../Task.js')
exports.XoMetadataBackup = class XoMetadataBackup {
constructor({ config, job, remoteAdapters, schedule, settings }) {

View File

@@ -6,7 +6,7 @@ const identity = require('lodash/identity.js')
const noop = Function.prototype
module.exports = function createStreamThrottle(rate) {
exports.createStreamThrottle = function createStreamThrottle(rate) {
if (rate === 0) {
return identity
}

View File

@@ -0,0 +1,9 @@
'use strict'
const getAdaptersByRemote = adapters => {
const adaptersByRemote = {}
adapters.forEach(({ adapter, remoteId }) => {
adaptersByRemote[remoteId] = adapter
})
return adaptersByRemote
}
exports.getAdaptersByRemote = getAdaptersByRemote

View File

@@ -0,0 +1,6 @@
'use strict'
const { Task } = require('../Task.js')
const noop = Function.prototype
const runTask = (...args) => Task.run(...args).catch(noop) // errors are handled by logs
exports.runTask = runTask

View File

@@ -13,10 +13,10 @@ const { createDebounceResource } = require('@vates/disposable/debounceResource.j
const { decorateMethodsWith } = require('@vates/decorate-with')
const { deduped } = require('@vates/disposable/deduped.js')
const { getHandler } = require('@xen-orchestra/fs')
const { instantiateBackupJob } = require('./backupJob.js')
const { parseDuration } = require('@vates/parse-duration')
const { Xapi } = require('@xen-orchestra/xapi')
const { Backup } = require('./Backup.js')
const { RemoteAdapter } = require('./RemoteAdapter.js')
const { Task } = require('./Task.js')
@@ -48,7 +48,7 @@ class BackupWorker {
}
run() {
return new Backup({
return instantiateBackupJob({
config: this.#config,
getAdapter: remoteId => this.getAdapter(this.#remotes[remoteId]),
getConnectedRecord: Disposable.factory(async function* getConnectedRecord(type, uuid) {

View File

@@ -3,7 +3,6 @@
const { beforeEach, afterEach, test, describe } = require('test')
const assert = require('assert').strict
const rimraf = require('rimraf')
const tmp = require('tmp')
const fs = require('fs-extra')
const uuid = require('uuid')
@@ -14,6 +13,7 @@ const { VHDFOOTER, VHDHEADER } = require('./tests.fixtures.js')
const { VhdFile, Constants, VhdDirectory, VhdAbstract } = require('vhd-lib')
const { checkAliases } = require('./_cleanVm')
const { dirname, basename } = require('path')
const { rimraf } = require('rimraf')
let tempDir, adapter, handler, jobId, vdiId, basePath, relativePath
const rootPath = 'xo-vm-backups/VMUUID/'

View File

@@ -33,7 +33,7 @@ const resolveUuid = async (xapi, cache, uuid, type) => {
return ref
}
exports.exportDeltaVm = async function exportDeltaVm(
exports.exportIncrementalVm = async function exportIncrementalVm(
vm,
baseVm,
{
@@ -143,18 +143,18 @@ exports.exportDeltaVm = async function exportDeltaVm(
)
}
exports.importDeltaVm = defer(async function importDeltaVm(
exports.importIncrementalVm = defer(async function importIncrementalVm(
$defer,
deltaVm,
incrementalVm,
sr,
{ cancelToken = CancelToken.none, detectBase = true, mapVdisSrs = {}, newMacAddresses = false } = {}
) {
const { version } = deltaVm
const { version } = incrementalVm
if (compareVersions(version, '1.0.0') < 0) {
throw new Error(`Unsupported delta backup version: ${version}`)
}
const vmRecord = deltaVm.vm
const vmRecord = incrementalVm.vm
const xapi = sr.$xapi
let baseVm
@@ -183,7 +183,7 @@ exports.importDeltaVm = defer(async function importDeltaVm(
baseVdis[vbd.VDI] = vbd.$VDI
}
})
const vdiRecords = deltaVm.vdis
const vdiRecords = incrementalVm.vdis
// 0. Create suspend_VDI
let suspendVdi
@@ -240,7 +240,7 @@ exports.importDeltaVm = defer(async function importDeltaVm(
await asyncMap(await xapi.getField('VM', vmRef, 'VBDs'), ref => ignoreErrors.call(xapi.call('VBD.destroy', ref)))
// 3. Create VDIs & VBDs.
const vbdRecords = deltaVm.vbds
const vbdRecords = incrementalVm.vbds
const vbds = groupBy(vbdRecords, 'VDI')
const newVdis = {}
await asyncMap(Object.keys(vdiRecords), async vdiRef => {
@@ -309,7 +309,7 @@ exports.importDeltaVm = defer(async function importDeltaVm(
}
})
const { streams } = deltaVm
const { streams } = incrementalVm
await Promise.all([
// Import VDI contents.
@@ -326,7 +326,7 @@ exports.importDeltaVm = defer(async function importDeltaVm(
}),
// Create VIFs.
asyncMap(Object.values(deltaVm.vifs), vif => {
asyncMap(Object.values(incrementalVm.vifs), vif => {
let network = vif.$network$uuid && xapi.getObjectByUuid(vif.$network$uuid, undefined)
if (network === undefined) {
@@ -358,8 +358,8 @@ exports.importDeltaVm = defer(async function importDeltaVm(
])
await Promise.all([
deltaVm.vm.ha_always_run && xapi.setField('VM', vmRef, 'ha_always_run', true),
xapi.setField('VM', vmRef, 'name_label', deltaVm.vm.name_label),
incrementalVm.vm.ha_always_run && xapi.setField('VM', vmRef, 'ha_always_run', true),
xapi.setField('VM', vmRef, 'name_label', incrementalVm.vm.name_label),
])
return vmRef

View File

@@ -0,0 +1,21 @@
'use strict'
const { MetadatasBackupJob } = require('./_backupJob/MetadatasBackupJob.js')
const { XapiVmBackupJob } = require('./_backupJob/XapiVMBackupJobs.js')
exports.instantiateBackupJob = function instantiateBackupJob({
config,
getAdapter,
getConnectedRecord,
job,
schedule,
}) {
switch (job.type) {
case 'backup':
return new XapiVmBackupJob({ config, getAdapter, getConnectedRecord, job, schedule })
case 'metadataBackup':
return new MetadatasBackupJob({ config, getAdapter, getConnectedRecord, job, schedule })
default:
throw new Error(`No runner for the backup type ${job.type}`)
}
}

View File

@@ -8,13 +8,13 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.36.0",
"version": "0.36.1",
"engines": {
"node": ">=14.6"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node--test"
"test-integration": "node--test *.integ.js"
},
"dependencies": {
"@kldzj/stream-throttle": "^1.1.1",
@@ -46,7 +46,7 @@
"yazl": "^2.5.1"
},
"devDependencies": {
"rimraf": "^4.1.1",
"rimraf": "^5.0.1",
"sinon": "^15.0.1",
"test": "^3.2.1",
"tmp": "^0.2.1"

View File

@@ -18,7 +18,7 @@
"preferGlobal": true,
"dependencies": {
"golike-defer": "^0.5.1",
"xen-api": "^1.3.0"
"xen-api": "^1.3.1"
},
"scripts": {
"postversion": "npm publish"

View File

@@ -53,7 +53,9 @@
"@babel/preset-env": "^7.8.0",
"cross-env": "^7.0.2",
"dotenv": "^16.0.0",
"rimraf": "^4.1.1",
"rimraf": "^5.0.1",
"sinon": "^15.0.4",
"test": "^3.3.0",
"tmp": "^0.2.1"
},
"scripts": {
@@ -63,7 +65,9 @@
"prebuild": "yarn run clean",
"predev": "yarn run clean",
"prepublishOnly": "yarn run build",
"postversion": "npm publish"
"pretest": "yarn run build",
"postversion": "npm publish",
"test": "node--test ./dist/"
},
"author": {
"name": "Vates SAS",

View File

@@ -1,4 +1,5 @@
/* eslint-env jest */
import { describe, it } from 'test'
import { strict as assert } from 'assert'
import { Readable } from 'readable-stream'
import copyStreamToBuffer from './_copyStreamToBuffer.js'
@@ -16,6 +17,6 @@ describe('copyStreamToBuffer', () => {
await copyStreamToBuffer(stream, buffer)
expect(buffer.toString()).toBe('hel')
assert.equal(buffer.toString(), 'hel')
})
})

View File

@@ -1,4 +1,5 @@
/* eslint-env jest */
import { describe, it } from 'test'
import { strict as assert } from 'assert'
import { Readable } from 'readable-stream'
import createBufferFromStream from './_createBufferFromStream.js'
@@ -14,6 +15,6 @@ describe('createBufferFromStream', () => {
const buffer = await createBufferFromStream(stream)
expect(buffer.toString()).toBe('hello')
assert.equal(buffer.toString(), 'hello')
})
})

View File

@@ -1,4 +1,6 @@
/* eslint-env jest */
import { describe, it } from 'test'
import { strict as assert } from 'assert'
import { Readable } from 'node:stream'
import { _getEncryptor } from './_encryptor'
import crypto from 'crypto'
@@ -25,13 +27,13 @@ algorithms.forEach(algorithm => {
it('handle buffer', () => {
const encrypted = encryptor.encryptData(buffer)
if (algorithm !== 'none') {
expect(encrypted.equals(buffer)).toEqual(false) // encrypted should be different
assert.equal(encrypted.equals(buffer), false) // encrypted should be different
// ivlength, auth tag, padding
expect(encrypted.length).not.toEqual(buffer.length)
assert.notEqual(encrypted.length, buffer.length)
}
const decrypted = encryptor.decryptData(encrypted)
expect(decrypted.equals(buffer)).toEqual(true)
assert.equal(decrypted.equals(buffer), true)
})
it('handle stream', async () => {
@@ -39,12 +41,12 @@ algorithms.forEach(algorithm => {
stream.length = buffer.length
const encrypted = encryptor.encryptStream(stream)
if (algorithm !== 'none') {
expect(encrypted.length).toEqual(undefined)
assert.equal(encrypted.length, undefined)
}
const decrypted = encryptor.decryptStream(encrypted)
const decryptedBuffer = await streamToBuffer(decrypted)
expect(decryptedBuffer.equals(buffer)).toEqual(true)
assert.equal(decryptedBuffer.equals(buffer), true)
})
})
})

View File

@@ -1,4 +1,5 @@
/* eslint-env jest */
import { describe, it } from 'test'
import { strict as assert } from 'assert'
import guessAwsRegion from './_guessAwsRegion.js'
@@ -6,12 +7,12 @@ describe('guessAwsRegion', () => {
it('should return region from AWS URL', async () => {
const region = guessAwsRegion('s3.test-region.amazonaws.com')
expect(region).toBe('test-region')
assert.equal(region, 'test-region')
})
it('should return default region if none is found is AWS URL', async () => {
const region = guessAwsRegion('s3.amazonaws.com')
expect(region).toBe('us-east-1')
assert.equal(region, 'us-east-1')
})
})

View File

@@ -1,11 +1,13 @@
/* eslint-env jest */
import { after, beforeEach, describe, it } from 'test'
import { strict as assert } from 'assert'
import sinon from 'sinon'
import { DEFAULT_ENCRYPTION_ALGORITHM, _getEncryptor } from './_encryptor'
import { Disposable, pFromCallback, TimeoutError } from 'promise-toolbox'
import { getSyncedHandler } from '.'
import { rimraf } from 'rimraf'
import AbstractHandler from './abstract'
import fs from 'fs-extra'
import rimraf from 'rimraf'
import tmp from 'tmp'
const TIMEOUT = 10e3
@@ -24,7 +26,7 @@ class TestHandler extends AbstractHandler {
const noop = Function.prototype
jest.useFakeTimers()
const clock = sinon.useFakeTimers()
describe('closeFile()', () => {
it(`throws in case of timeout`, async () => {
@@ -33,8 +35,8 @@ describe('closeFile()', () => {
})
const promise = testHandler.closeFile({ fd: undefined, path: '' })
jest.advanceTimersByTime(TIMEOUT)
await expect(promise).rejects.toThrowError(TimeoutError)
clock.tick(TIMEOUT)
await assert.rejects(promise, TimeoutError)
})
})
@@ -45,8 +47,8 @@ describe('getInfo()', () => {
})
const promise = testHandler.getInfo()
jest.advanceTimersByTime(TIMEOUT)
await expect(promise).rejects.toThrowError(TimeoutError)
clock.tick(TIMEOUT)
await assert.rejects(promise, TimeoutError)
})
})
@@ -57,8 +59,8 @@ describe('getSize()', () => {
})
const promise = testHandler.getSize('')
jest.advanceTimersByTime(TIMEOUT)
await expect(promise).rejects.toThrowError(TimeoutError)
clock.tick(TIMEOUT)
await assert.rejects(promise, TimeoutError)
})
})
@@ -69,8 +71,8 @@ describe('list()', () => {
})
const promise = testHandler.list('.')
jest.advanceTimersByTime(TIMEOUT)
await expect(promise).rejects.toThrowError(TimeoutError)
clock.tick(TIMEOUT)
await assert.rejects(promise, TimeoutError)
})
})
@@ -81,8 +83,8 @@ describe('openFile()', () => {
})
const promise = testHandler.openFile('path')
jest.advanceTimersByTime(TIMEOUT)
await expect(promise).rejects.toThrowError(TimeoutError)
clock.tick(TIMEOUT)
await assert.rejects(promise, TimeoutError)
})
})
@@ -93,8 +95,8 @@ describe('rename()', () => {
})
const promise = testHandler.rename('oldPath', 'newPath')
jest.advanceTimersByTime(TIMEOUT)
await expect(promise).rejects.toThrowError(TimeoutError)
clock.tick(TIMEOUT)
await assert.rejects(promise, TimeoutError)
})
})
@@ -105,8 +107,8 @@ describe('rmdir()', () => {
})
const promise = testHandler.rmdir('dir')
jest.advanceTimersByTime(TIMEOUT)
await expect(promise).rejects.toThrowError(TimeoutError)
clock.tick(TIMEOUT)
await assert.rejects(promise, TimeoutError)
})
})
@@ -115,14 +117,14 @@ describe('encryption', () => {
beforeEach(async () => {
dir = await pFromCallback(cb => tmp.dir(cb))
})
afterAll(async () => {
after(async () => {
await rimraf(dir)
})
it('sync should NOT create metadata if missing (not encrypted)', async () => {
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }), noop)
expect(await fs.readdir(dir)).toEqual([])
assert.deepEqual(await fs.readdir(dir), [])
})
it('sync should create metadata if missing (encrypted)', async () => {
@@ -131,12 +133,12 @@ describe('encryption', () => {
noop
)
expect(await fs.readdir(dir)).toEqual(['encryption.json', 'metadata.json'])
assert.deepEqual(await fs.readdir(dir), ['encryption.json', 'metadata.json'])
const encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
expect(encryption.algorithm).toEqual(DEFAULT_ENCRYPTION_ALGORITHM)
assert.equal(encryption.algorithm, DEFAULT_ENCRYPTION_ALGORITHM)
// encrypted , should not be parsable
expect(async () => JSON.parse(await fs.readFile(`${dir}/metadata.json`))).rejects.toThrowError()
assert.rejects(async () => JSON.parse(await fs.readFile(`${dir}/metadata.json`)))
})
it('sync should not modify existing metadata', async () => {
@@ -146,9 +148,9 @@ describe('encryption', () => {
await Disposable.use(await getSyncedHandler({ url: `file://${dir}` }), noop)
const encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
expect(encryption.algorithm).toEqual('none')
assert.equal(encryption.algorithm, 'none')
const metadata = JSON.parse(await fs.readFile(`${dir}/metadata.json`, 'utf-8'))
expect(metadata.random).toEqual('NOTSORANDOM')
assert.equal(metadata.random, 'NOTSORANDOM')
})
it('should modify metadata if empty', async () => {
@@ -160,11 +162,11 @@ describe('encryption', () => {
noop
)
let encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
expect(encryption.algorithm).toEqual(DEFAULT_ENCRYPTION_ALGORITHM)
assert.equal(encryption.algorithm, DEFAULT_ENCRYPTION_ALGORITHM)
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }), noop)
encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
expect(encryption.algorithm).toEqual('none')
assert.equal(encryption.algorithm, 'none')
})
it(
@@ -178,9 +180,9 @@ describe('encryption', () => {
const handler = yield getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd91"` })
const encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
expect(encryption.algorithm).toEqual(DEFAULT_ENCRYPTION_ALGORITHM)
assert.equal(encryption.algorithm, DEFAULT_ENCRYPTION_ALGORITHM)
const metadata = JSON.parse(await handler.readFile(`./metadata.json`))
expect(metadata.random).toEqual('NOTSORANDOM')
assert.equal(metadata.random, 'NOTSORANDOM')
})
)
@@ -198,9 +200,9 @@ describe('encryption', () => {
// remote is now non empty : can't modify key anymore
await fs.writeFile(`${dir}/nonempty.json`, 'content')
await expect(
await assert.rejects(
Disposable.use(getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd10"` }), noop)
).rejects.toThrowError()
)
})
it('sync should fail when changing algorithm', async () => {
@@ -213,8 +215,8 @@ describe('encryption', () => {
// remote is now non empty : can't modify key anymore
await fs.writeFile(`${dir}/nonempty.json`, 'content')
await expect(
await assert.rejects(
Disposable.use(getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd91"` }), noop)
).rejects.toThrowError()
)
})
})

View File

@@ -1,4 +1,5 @@
/* eslint-env jest */
import { after, afterEach, before, beforeEach, describe, it } from 'test'
import { strict as assert } from 'assert'
import 'dotenv/config'
import { forOwn, random } from 'lodash'
@@ -53,11 +54,11 @@ handlers.forEach(url => {
})
}
beforeAll(async () => {
before(async () => {
handler = getHandler({ url }).addPrefix(`xo-fs-tests-${Date.now()}`)
await handler.sync()
})
afterAll(async () => {
after(async () => {
await handler.forget()
handler = undefined
})
@@ -72,67 +73,63 @@ handlers.forEach(url => {
describe('#type', () => {
it('returns the type of the remote', () => {
expect(typeof handler.type).toBe('string')
assert.equal(typeof handler.type, 'string')
})
})
describe('#getInfo()', () => {
let info
beforeAll(async () => {
before(async () => {
info = await handler.getInfo()
})
it('should return an object with info', async () => {
expect(typeof info).toBe('object')
assert.equal(typeof info, 'object')
})
it('should return correct type of attribute', async () => {
if (info.size !== undefined) {
expect(typeof info.size).toBe('number')
assert.equal(typeof info.size, 'number')
}
if (info.used !== undefined) {
expect(typeof info.used).toBe('number')
assert.equal(typeof info.used, 'number')
}
})
})
describe('#getSize()', () => {
beforeEach(() => handler.outputFile('file', TEST_DATA))
before(() => handler.outputFile('file', TEST_DATA))
testWithFileDescriptor('file', 'r', async () => {
expect(await handler.getSize('file')).toEqual(TEST_DATA_LEN)
assert.equal(await handler.getSize('file'), TEST_DATA_LEN)
})
})
describe('#list()', () => {
it(`should list the content of folder`, async () => {
await handler.outputFile('file', TEST_DATA)
await expect(await handler.list('.')).toEqual(['file'])
assert.deepEqual(await handler.list('.'), ['file'])
})
it('can prepend the directory to entries', async () => {
await handler.outputFile('dir/file', '')
expect(await handler.list('dir', { prependDir: true })).toEqual(['/dir/file'])
})
it('can prepend the directory to entries', async () => {
await handler.outputFile('dir/file', '')
expect(await handler.list('dir', { prependDir: true })).toEqual(['/dir/file'])
assert.deepEqual(await handler.list('dir', { prependDir: true }), ['/dir/file'])
})
it('throws ENOENT if no such directory', async () => {
expect((await rejectionOf(handler.list('dir'))).code).toBe('ENOENT')
await handler.rmtree('dir')
assert.equal((await rejectionOf(handler.list('dir'))).code, 'ENOENT')
})
it('can returns empty for missing directory', async () => {
expect(await handler.list('dir', { ignoreMissing: true })).toEqual([])
assert.deepEqual(await handler.list('dir', { ignoreMissing: true }), [])
})
})
describe('#mkdir()', () => {
it('creates a directory', async () => {
await handler.mkdir('dir')
await expect(await handler.list('.')).toEqual(['dir'])
assert.deepEqual(await handler.list('.'), ['dir'])
})
it('does not throw on existing directory', async () => {
@@ -143,15 +140,15 @@ handlers.forEach(url => {
it('throws ENOTDIR on existing file', async () => {
await handler.outputFile('file', '')
const error = await rejectionOf(handler.mkdir('file'))
expect(error.code).toBe('ENOTDIR')
assert.equal(error.code, 'ENOTDIR')
})
})
describe('#mktree()', () => {
it('creates a tree of directories', async () => {
await handler.mktree('dir/dir')
await expect(await handler.list('.')).toEqual(['dir'])
await expect(await handler.list('dir')).toEqual(['dir'])
assert.deepEqual(await handler.list('.'), ['dir'])
assert.deepEqual(await handler.list('dir'), ['dir'])
})
it('does not throw on existing directory', async () => {
@@ -162,26 +159,27 @@ handlers.forEach(url => {
it('throws ENOTDIR on existing file', async () => {
await handler.outputFile('dir/file', '')
const error = await rejectionOf(handler.mktree('dir/file'))
expect(error.code).toBe('ENOTDIR')
assert.equal(error.code, 'ENOTDIR')
})
it('throws ENOTDIR on existing file in path', async () => {
await handler.outputFile('file', '')
const error = await rejectionOf(handler.mktree('file/dir'))
expect(error.code).toBe('ENOTDIR')
assert.equal(error.code, 'ENOTDIR')
})
})
describe('#outputFile()', () => {
it('writes data to a file', async () => {
await handler.outputFile('file', TEST_DATA)
expect(await handler.readFile('file')).toEqual(TEST_DATA)
assert.deepEqual(await handler.readFile('file'), TEST_DATA)
})
it('throws on existing files', async () => {
await handler.unlink('file')
await handler.outputFile('file', '')
const error = await rejectionOf(handler.outputFile('file', ''))
expect(error.code).toBe('EEXIST')
assert.equal(error.code, 'EEXIST')
})
it("shouldn't timeout in case of the respect of the parallel execution restriction", async () => {
@@ -192,7 +190,7 @@ handlers.forEach(url => {
})
describe('#read()', () => {
beforeEach(() => handler.outputFile('file', TEST_DATA))
before(() => handler.outputFile('file', TEST_DATA))
const start = random(TEST_DATA_LEN)
const size = random(TEST_DATA_LEN)
@@ -200,8 +198,8 @@ handlers.forEach(url => {
testWithFileDescriptor('file', 'r', async ({ file }) => {
const buffer = Buffer.alloc(size)
const result = await handler.read(file, buffer, start)
expect(result.buffer).toBe(buffer)
expect(result).toEqual({
assert.deepEqual(result.buffer, buffer)
assert.deepEqual(result, {
buffer,
bytesRead: Math.min(size, TEST_DATA_LEN - start),
})
@@ -211,12 +209,13 @@ handlers.forEach(url => {
describe('#readFile', () => {
it('returns a buffer containing the contents of the file', async () => {
await handler.outputFile('file', TEST_DATA)
expect(await handler.readFile('file')).toEqual(TEST_DATA)
assert.deepEqual(await handler.readFile('file'), TEST_DATA)
})
it('throws on missing file', async () => {
await handler.unlink('file')
const error = await rejectionOf(handler.readFile('file'))
expect(error.code).toBe('ENOENT')
assert.equal(error.code, 'ENOENT')
})
})
@@ -225,19 +224,19 @@ handlers.forEach(url => {
await handler.outputFile('file', TEST_DATA)
await handler.rename('file', `file2`)
expect(await handler.list('.')).toEqual(['file2'])
expect(await handler.readFile(`file2`)).toEqual(TEST_DATA)
assert.deepEqual(await handler.list('.'), ['file2'])
assert.deepEqual(await handler.readFile(`file2`), TEST_DATA)
})
it(`should rename the file and create dest directory`, async () => {
await handler.outputFile('file', TEST_DATA)
await handler.rename('file', `sub/file2`)
expect(await handler.list('sub')).toEqual(['file2'])
expect(await handler.readFile(`sub/file2`)).toEqual(TEST_DATA)
assert.deepEqual(await handler.list('sub'), ['file2'])
assert.deepEqual(await handler.readFile(`sub/file2`), TEST_DATA)
})
it(`should fail with enoent if source file is missing`, async () => {
const error = await rejectionOf(handler.rename('file', `sub/file2`))
expect(error.code).toBe('ENOENT')
assert.equal(error.code, 'ENOENT')
})
})
@@ -245,14 +244,15 @@ handlers.forEach(url => {
it('should remove an empty directory', async () => {
await handler.mkdir('dir')
await handler.rmdir('dir')
expect(await handler.list('.')).toEqual([])
assert.deepEqual(await handler.list('.'), [])
})
it(`should throw on non-empty directory`, async () => {
await handler.outputFile('dir/file', '')
const error = await rejectionOf(handler.rmdir('.'))
await expect(error.code).toEqual('ENOTEMPTY')
assert.equal(error.code, 'ENOTEMPTY')
await handler.unlink('dir/file')
})
it('does not throw on missing directory', async () => {
@@ -265,7 +265,7 @@ handlers.forEach(url => {
await handler.outputFile('dir/file', '')
await handler.rmtree('dir')
expect(await handler.list('.')).toEqual([])
assert.deepEqual(await handler.list('.'), [])
})
})
@@ -273,9 +273,9 @@ handlers.forEach(url => {
it('tests the remote appears to be working', async () => {
const answer = await handler.test()
expect(answer.success).toBe(true)
expect(typeof answer.writeRate).toBe('number')
expect(typeof answer.readRate).toBe('number')
assert.equal(answer.success, true)
assert.equal(typeof answer.writeRate, 'number')
assert.equal(typeof answer.readRate, 'number')
})
})
@@ -284,7 +284,7 @@ handlers.forEach(url => {
await handler.outputFile('file', TEST_DATA)
await handler.unlink('file')
await expect(await handler.list('.')).toEqual([])
assert.deepEqual(await handler.list('.'), [])
})
it('does not throw on missing file', async () => {
@@ -294,6 +294,7 @@ handlers.forEach(url => {
describe('#write()', () => {
beforeEach(() => handler.outputFile('file', TEST_DATA))
afterEach(() => handler.unlink('file'))
const PATCH_DATA_LEN = Math.ceil(TEST_DATA_LEN / 2)
const PATCH_DATA = unsecureRandomBytes(PATCH_DATA_LEN)
@@ -322,7 +323,7 @@ handlers.forEach(url => {
describe(title, () => {
testWithFileDescriptor('file', 'r+', async ({ file }) => {
await handler.write(file, PATCH_DATA, offset)
await expect(await handler.readFile('file')).toEqual(expected)
assert.deepEqual(await handler.readFile('file'), expected)
})
})
}
@@ -330,6 +331,7 @@ handlers.forEach(url => {
})
describe('#truncate()', () => {
afterEach(() => handler.unlink('file'))
forOwn(
{
'shrinks file': (() => {
@@ -348,7 +350,7 @@ handlers.forEach(url => {
it(title, async () => {
await handler.outputFile('file', TEST_DATA)
await handler.truncate('file', length)
await expect(await handler.readFile('file')).toEqual(expected)
assert.deepEqual(await handler.readFile('file'), expected)
})
}
)

View File

@@ -1,9 +1,5 @@
# ChangeLog
## **next**
- Add ability to open VM console in new window (PR [#6827](https://github.com/vatesfr/xen-orchestra/pull/6827))
## **0.2.0**
- Invalidate sessionId token after logout (PR [#6480](https://github.com/vatesfr/xen-orchestra/pull/6480))

View File

@@ -1,32 +1,12 @@
<template>
<UiModal
v-if="isSslModalOpen"
:icon="faServer"
color="error"
@close="clearUnreachableHostsUrls"
>
<template #title>{{ $t("unreachable-hosts") }}</template>
<template #subtitle>{{ $t("following-hosts-unreachable") }}</template>
<p>{{ $t("allow-self-signed-ssl") }}</p>
<ul>
<li v-for="url in unreachableHostsUrls" :key="url.hostname">
<a :href="url.href" rel="noopener" target="_blank">{{ url.href }}</a>
</li>
</ul>
<template #buttons>
<UiButton color="success" @click="reload">
{{ $t("unreachable-hosts-reload-page") }}
</UiButton>
<UiButton @click="clearUnreachableHostsUrls">{{ $t("cancel") }}</UiButton>
</template>
</UiModal>
<UnreachableHostsModal />
<div v-if="!$route.meta.hasStoryNav && !xenApiStore.isConnected">
<AppLogin />
</div>
<div v-else>
<AppHeader v-if="uiStore.hasUi" />
<AppHeader />
<div style="display: flex">
<AppNavigation v-if="uiStore.hasUi" />
<AppNavigation />
<main class="main">
<RouterView />
</main>
@@ -41,21 +21,14 @@ import AppHeader from "@/components/AppHeader.vue";
import AppLogin from "@/components/AppLogin.vue";
import AppNavigation from "@/components/AppNavigation.vue";
import AppTooltips from "@/components/AppTooltips.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiModal from "@/components/ui/UiModal.vue";
import UnreachableHostsModal from "@/components/UnreachableHostsModal.vue";
import { useChartTheme } from "@/composables/chart-theme.composable";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useUiStore } from "@/stores/ui.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import { useActiveElement, useMagicKeys, whenever } from "@vueuse/core";
import { logicAnd } from "@vueuse/math";
import { difference } from "lodash-es";
import { computed, ref, watch } from "vue";
const unreachableHostsUrls = ref<URL[]>([]);
const clearUnreachableHostsUrls = () => (unreachableHostsUrls.value = []);
import { computed } from "vue";
let link = document.querySelector(
"link[rel~='icon']"
@@ -70,7 +43,6 @@ link.href = favicon;
document.title = "XO Lite";
const xenApiStore = useXenApiStore();
const { records: hosts } = useHostStore().subscribe();
const { pool } = usePoolStore().subscribe();
useChartTheme();
const uiStore = useUiStore();
@@ -93,17 +65,6 @@ if (import.meta.env.DEV) {
);
}
watch(hosts, (hosts, previousHosts) => {
difference(hosts, previousHosts).forEach((host) => {
const url = new URL("http://localhost");
url.protocol = window.location.protocol;
url.hostname = host.address;
fetch(url, { mode: "no-cors" }).catch(() =>
unreachableHostsUrls.value.push(url)
);
});
});
whenever(
() => pool.value?.$ref,
async (poolRef) => {
@@ -112,9 +73,6 @@ whenever(
await xenApi.startWatch();
}
);
const isSslModalOpen = computed(() => unreachableHostsUrls.value.length > 0);
const reload = () => window.location.reload();
</script>
<style lang="postcss">

View File

@@ -1,15 +1,15 @@
<template>
<div v-if="!isDisabled" ref="tooltipElement" class="app-tooltip">
<span class="triangle" />
<span class="label">{{ content }}</span>
<span class="label">{{ options.content }}</span>
</div>
</template>
<script lang="ts" setup>
import { isEmpty, isFunction, isString } from "lodash-es";
import type { TooltipOptions } from "@/stores/tooltip.store";
import { isString } from "lodash-es";
import place from "placement.js";
import { computed, ref, watchEffect } from "vue";
import type { TooltipOptions } from "@/stores/tooltip.store";
const props = defineProps<{
target: HTMLElement;
@@ -18,29 +18,13 @@ const props = defineProps<{
const tooltipElement = ref<HTMLElement>();
const content = computed(() =>
isString(props.options) ? props.options : props.options.content
const isDisabled = computed(() =>
isString(props.options.content)
? props.options.content.trim() === ""
: props.options.content === false
);
const isDisabled = computed(() => {
if (isEmpty(content.value)) {
return true;
}
if (isString(props.options)) {
return false;
}
if (isFunction(props.options.disabled)) {
return props.options.disabled(props.target);
}
return props.options.disabled ?? false;
});
const placement = computed(() =>
isString(props.options) ? "top" : props.options.placement ?? "top"
);
const placement = computed(() => props.options.placement ?? "top");
watchEffect(() => {
if (tooltipElement.value) {

View File

@@ -1,5 +1,5 @@
<template>
<div ref="consoleContainer" class="remote-console" />
<div ref="vmConsoleContainer" class="vm-console" />
</template>
<script lang="ts" setup>
@@ -19,7 +19,7 @@ const props = defineProps<{
isConsoleAvailable: boolean;
}>();
const consoleContainer = ref<HTMLDivElement>();
const vmConsoleContainer = ref<HTMLDivElement>();
const xenApiStore = useXenApiStore();
const url = computed(() => {
if (xenApiStore.currentSessionId == null) {
@@ -78,7 +78,7 @@ const createVncConnection = async () => {
await promiseTimeout(FIBONACCI_MS_ARRAY[nConnectionAttempts - 1]);
}
vncClient = new VncClient(consoleContainer.value!, url.value!.toString(), {
vncClient = new VncClient(vmConsoleContainer.value!, url.value!.toString(), {
wsProtocols: ["binary"],
});
vncClient.scaleViewport = true;
@@ -91,7 +91,7 @@ watch(url, clearVncClient);
watchEffect(() => {
if (
url.value === undefined ||
consoleContainer.value === undefined ||
vmConsoleContainer.value === undefined ||
!props.isConsoleAvailable
) {
return;
@@ -107,8 +107,8 @@ onBeforeUnmount(() => {
</script>
<style lang="postcss" scoped>
.remote-console {
height: 100%;
.vm-console {
height: 80rem;
& > :deep(div) {
background-color: transparent !important;

View File

@@ -0,0 +1,59 @@
<template>
<UiModal
v-if="isSslModalOpen"
:icon="faServer"
color="error"
@close="clearUnreachableHostsUrls"
>
<template #title>{{ $t("unreachable-hosts") }}</template>
<div class="description">
<p>{{ $t("following-hosts-unreachable") }}</p>
<p>{{ $t("allow-self-signed-ssl") }}</p>
<ul>
<li v-for="url in unreachableHostsUrls" :key="url">
<a :href="url" class="link" rel="noopener" target="_blank">{{
url
}}</a>
</li>
</ul>
</div>
<template #buttons>
<UiButton color="success" @click="reload">
{{ $t("unreachable-hosts-reload-page") }}
</UiButton>
<UiButton @click="clearUnreachableHostsUrls">{{ $t("cancel") }}</UiButton>
</template>
</UiModal>
</template>
<script lang="ts" setup>
import { faServer } from "@fortawesome/free-solid-svg-icons";
import UiModal from "@/components/ui/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { computed, ref, watch } from "vue";
import { difference } from "lodash";
import { useHostStore } from "@/stores/host.store";
const { records: hosts } = useHostStore().subscribe();
const unreachableHostsUrls = ref<Set<string>>(new Set());
const clearUnreachableHostsUrls = () => unreachableHostsUrls.value.clear();
const isSslModalOpen = computed(() => unreachableHostsUrls.value.size > 0);
const reload = () => window.location.reload();
watch(hosts, (nextHosts, previousHosts) => {
difference(nextHosts, previousHosts).forEach((host) => {
const url = new URL("http://localhost");
url.protocol = window.location.protocol;
url.hostname = host.address;
fetch(url, { mode: "no-cors" }).catch(() =>
unreachableHostsUrls.value.add(url.toString())
);
});
});
</script>
<style lang="postcss" scoped>
.description p {
margin: 1rem 0;
}
</style>

View File

@@ -1,12 +1,5 @@
<template>
<li
v-if="host !== undefined"
v-tooltip="{
content: host.name_label,
disabled: isTooltipDisabled,
}"
class="infra-host-item"
>
<li v-if="host !== undefined" class="infra-host-item">
<InfraItemLabel
:active="isCurrentHost"
:icon="faServer"
@@ -36,7 +29,6 @@ import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { hasEllipsis } from "@/libs/utils";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useUiStore } from "@/stores/ui.store";
@@ -66,9 +58,6 @@ const isCurrentHost = computed(
() => props.hostOpaqueRef === uiStore.currentHostOpaqueRef
);
const [isExpanded, toggle] = useToggle(true);
const isTooltipDisabled = (target: HTMLElement) =>
!hasEllipsis(target.querySelector(".text"));
</script>
<style lang="postcss" scoped>

View File

@@ -7,9 +7,9 @@
class="infra-item-label"
v-bind="$attrs"
>
<a :href="href" class="link" @click="navigate">
<a :href="href" class="link" @click="navigate" v-tooltip="hasTooltip">
<UiIcon :icon="icon" class="icon" />
<div class="text">
<div ref="textElement" class="text">
<slot />
</div>
</a>
@@ -22,7 +22,10 @@
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { hasEllipsis } from "@/libs/utils";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { computed, ref } from "vue";
import type { RouteLocationRaw } from "vue-router";
defineProps<{
@@ -30,6 +33,9 @@ defineProps<{
route: RouteLocationRaw;
active?: boolean;
}>();
const textElement = ref<HTMLElement>();
const hasTooltip = computed(() => hasEllipsis(textElement.value));
</script>
<style lang="postcss" scoped>

View File

@@ -1,13 +1,5 @@
<template>
<li
v-if="vm !== undefined"
ref="rootElement"
v-tooltip="{
content: vm.name_label,
disabled: isTooltipDisabled,
}"
class="infra-vm-item"
>
<li v-if="vm !== undefined" ref="rootElement" class="infra-vm-item">
<InfraItemLabel
v-if="isVisible"
:icon="faDisplay"
@@ -27,8 +19,6 @@
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { hasEllipsis } from "@/libs/utils";
import { useVmStore } from "@/stores/vm.store";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { useIntersectionObserver } from "@vueuse/core";
@@ -49,9 +39,6 @@ const { stop } = useIntersectionObserver(rootElement, ([entry]) => {
stop();
}
});
const isTooltipDisabled = (target: HTMLElement) =>
!hasEllipsis(target.querySelector(".text"));
</script>
<style lang="postcss" scoped>

View File

@@ -16,6 +16,7 @@ defineProps<{
<style lang="postcss" scoped>
.ui-badge {
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 0.4rem;

View File

@@ -1,7 +1,13 @@
<template>
<div class="legend">
<span class="circle" />
<slot name="label">{{ label }}</slot>
<template v-if="$slots.label || label">
<span class="circle" />
<div class="label-container">
<div ref="labelElement" v-tooltip="isTooltipEnabled" class="label">
<slot name="label">{{ label }}</slot>
</div>
</div>
</template>
<UiBadge class="badge">
<slot name="value">{{ value }}</slot>
</UiBadge>
@@ -10,14 +16,23 @@
<script lang="ts" setup>
import UiBadge from "@/components/ui/UiBadge.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { hasEllipsis } from "@/libs/utils";
import { computed, ref } from "vue";
defineProps<{
label?: string;
value?: string;
}>();
const labelElement = ref<HTMLElement>();
const isTooltipEnabled = computed(() =>
hasEllipsis(labelElement.value, { vertical: true })
);
</script>
<style scoped lang="postcss">
<style lang="postcss" scoped>
.badge {
font-size: 0.9em;
font-weight: 700;
@@ -25,8 +40,8 @@ defineProps<{
.circle {
display: inline-block;
width: 1rem;
height: 1rem;
min-width: 1rem;
min-height: 1rem;
border-radius: 0.5rem;
background-color: var(--progress-bar-color);
}
@@ -38,4 +53,14 @@ defineProps<{
gap: 0.5rem;
margin: 1.6em 0;
}
.label-container {
overflow: hidden;
}
.label {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<UiTabBar>
<RouterTab :to="{ name: 'vm.dashboard', params: { uuid } }">
{{ $t("dashboard") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.console', params: { uuid } }">
{{ $t("console") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.alarms', params: { uuid } }">
{{ $t("alarms") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.stats', params: { uuid } }">
{{ $t("stats") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.system', params: { uuid } }">
{{ $t("system") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.network', params: { uuid } }">
{{ $t("network") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.storage', params: { uuid } }">
{{ $t("storage") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.tasks', params: { uuid } }">
{{ $t("tasks") }}
</RouterTab>
</UiTabBar>
</template>
<script lang="ts" setup>
import RouterTab from "@/components/RouterTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
defineProps<{
uuid: string;
}>();
</script>

View File

@@ -1,36 +1,71 @@
# Tooltip Directive
By default, tooltip will appear centered above the target element.
By default, the tooltip will appear centered above the target element.
## Directive argument
The directive argument can be either:
- The tooltip content
- An object containing the tooltip content and/or placement: `{ content: "...", placement: "..." }` (both optional)
## Tooltip content
The tooltip content can be either:
- `false` or an empty-string to disable the tooltip
- `true` or `undefined` to enable the tooltip and extract its content from the element's innerText.
- Non-empty string to enable the tooltip and use the string as content.
## Tooltip placement
Tooltip can be placed on the following positions:
- `top`
- `top-start`
- `top-end`
- `bottom`
- `bottom-start`
- `bottom-end`
- `left`
- `left-start`
- `left-end`
- `right`
- `right-start`
- `right-end`
## Usage
```vue
<template>
<!-- Static -->
<!-- Boolean / Undefined -->
<span v-tooltip="true"
>This content will be ellipsized by CSS but displayed entirely in the
tooltip</span
>
<span v-tooltip
>This content will be ellipsized by CSS but displayed entirely in the
tooltip</span
>
<!-- String -->
<span v-tooltip="'Tooltip content'">Item</span>
<!-- Dynamic -->
<span v-tooltip="myTooltipContent">Item</span>
<!-- Placement -->
<!-- Object -->
<span v-tooltip="{ content: 'Foobar', placement: 'left-end' }">Item</span>
<!-- Disabling (variable) -->
<span v-tooltip="{ content: 'Foobar', disabled: isDisabled }">Item</span>
<!-- Dynamic -->
<span v-tooltip="myTooltip">Item</span>
<!-- Disabling (function) -->
<span v-tooltip="{ content: 'Foobar', disabled: isDisabledFn }">Item</span>
<!-- Conditional -->
<span v-tooltip="isTooltipEnabled && 'Foobar'">Item</span>
</template>
<script setup>
import { ref } from "vue";
import { vTooltip } from "@/directives/tooltip.directive";
const myTooltipContent = ref("Content");
const isDisabled = ref(true);
const isDisabledFn = (target: Element) => {
// return boolean;
};
const myTooltip = ref("Content"); // or ref({ content: "Content", placement: "left-end" })
const isTooltipEnabled = ref(true);
</script>
```

View File

@@ -1,8 +1,36 @@
import type { Directive } from "vue";
import type { TooltipEvents, TooltipOptions } from "@/stores/tooltip.store";
import { useTooltipStore } from "@/stores/tooltip.store";
import { isObject } from "lodash-es";
import type { Options } from "placement.js";
import type { Directive } from "vue";
export const vTooltip: Directive<HTMLElement, TooltipOptions> = {
type TooltipDirectiveContent = undefined | boolean | string;
type TooltipDirectiveOptions =
| TooltipDirectiveContent
| {
content?: TooltipDirectiveContent;
placement?: Options["placement"];
};
const parseOptions = (
options: TooltipDirectiveOptions,
target: HTMLElement
): TooltipOptions => {
const { placement, content } = isObject(options)
? options
: { placement: undefined, content: options };
return {
placement,
content:
content === true || content === undefined
? target.innerText.trim()
: content,
};
};
export const vTooltip: Directive<HTMLElement, TooltipDirectiveOptions> = {
mounted(target, binding) {
const store = useTooltipStore();
@@ -10,11 +38,11 @@ export const vTooltip: Directive<HTMLElement, TooltipOptions> = {
? { on: "focusin", off: "focusout" }
: { on: "mouseenter", off: "mouseleave" };
store.register(target, binding.value, events);
store.register(target, parseOptions(binding.value, target), events);
},
updated(target, binding) {
const store = useTooltipStore();
store.updateOptions(target, binding.value);
store.updateOptions(target, parseOptions(binding.value, target));
},
beforeUnmount(target) {
const store = useTooltipStore();

View File

@@ -71,8 +71,20 @@ export function parseDateTime(dateTime: string) {
return date.getTime();
}
export const hasEllipsis = (target: Element | undefined | null) =>
target != undefined && target.clientWidth < target.scrollWidth;
export const hasEllipsis = (
target: Element | undefined | null,
{ vertical = false }: { vertical?: boolean } = {}
) => {
if (target == null) {
return false;
}
if (vertical) {
return target.clientHeight < target.scrollHeight;
}
return target.clientWidth < target.scrollWidth;
};
export function percent(currentValue: number, maxValue: number, precision = 2) {
return round((currentValue / maxValue) * 100, precision);

View File

@@ -17,6 +17,7 @@
"coming-soon": "Coming soon!",
"community": "Community",
"community-name": "{name} community",
"console": "Console",
"copy": "Copy",
"cpu-provisioning": "CPU provisioning",
"cpu-usage": "CPU usage",

View File

@@ -17,6 +17,7 @@
"coming-soon": "Bientôt disponible !",
"community": "Communauté",
"community-name": "Communauté {name}",
"console": "Console",
"copy": "Copier",
"cpu-provisioning": "Provisionnement CPU",
"cpu-usage": "Utilisation CPU",

View File

@@ -1,12 +1,11 @@
import pool from "@/router/pool";
import vm from "@/router/vm";
import HomeView from "@/views/HomeView.vue";
import HostDashboardView from "@/views/host/HostDashboardView.vue";
import HostRootView from "@/views/host/HostRootView.vue";
import PageNotFoundView from "@/views/PageNotFoundView.vue";
import SettingsView from "@/views/settings/SettingsView.vue";
import StoryView from "@/views/StoryView.vue";
import VmConsoleView from "@/views/vm/VmConsoleView.vue";
import VmRootView from "@/views/vm/VmRootView.vue";
import storiesRoutes from "virtual:stories";
import { createRouter, createWebHashHistory } from "vue-router";
@@ -31,6 +30,7 @@ const router = createRouter({
component: SettingsView,
},
pool,
vm,
{
path: "/host/:uuid",
component: HostRootView,
@@ -42,17 +42,6 @@ const router = createRouter({
},
],
},
{
path: "/vm/:uuid",
component: VmRootView,
children: [
{
path: "console",
name: "vm.console",
component: VmConsoleView,
},
],
},
{
path: "/:pathMatch(.*)*",
name: "notFound",

View File

@@ -0,0 +1,47 @@
export default {
path: "/vm/:uuid",
component: () => import("@/views/vm/VmRootView.vue"),
redirect: { name: "vm.console" },
children: [
{
path: "dashboard",
name: "vm.dashboard",
component: () => import("@/views/vm/VmDashboardView.vue"),
},
{
path: "console",
name: "vm.console",
component: () => import("@/views/vm/VmConsoleView.vue"),
},
{
path: "alarms",
name: "vm.alarms",
component: () => import("@/views/vm/VmAlarmsView.vue"),
},
{
path: "stats",
name: "vm.stats",
component: () => import("@/views/vm/VmStatsView.vue"),
},
{
path: "system",
name: "vm.system",
component: () => import("@/views/vm/VmSystemView.vue"),
},
{
path: "network",
name: "vm.network",
component: () => import("@/views/vm/VmNetworkView.vue"),
},
{
path: "storage",
name: "vm.storage",
component: () => import("@/views/vm/VmStorageView.vue"),
},
{
path: "tasks",
name: "vm.tasks",
component: () => import("@/views/vm/VmTasksView.vue"),
},
],
};

View File

@@ -4,13 +4,10 @@ import type { Options } from "placement.js";
import { type EffectScope, computed, effectScope, ref } from "vue";
import { type WindowEventName, useEventListener } from "@vueuse/core";
export type TooltipOptions =
| string
| {
content: string;
placement?: Options["placement"];
disabled?: boolean | ((target: HTMLElement) => boolean);
};
export type TooltipOptions = {
content: string | false;
placement: Options["placement"];
};
export type TooltipEvents = { on: WindowEventName; off: WindowEventName };

View File

@@ -1,7 +1,6 @@
import { useBreakpoints, useColorMode } from "@vueuse/core";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { useRoute } from "vue-router";
export const useUiStore = defineStore("ui", () => {
const currentHostOpaqueRef = ref();
@@ -14,15 +13,10 @@ export const useUiStore = defineStore("ui", () => {
const isMobile = computed(() => !isDesktop.value);
const route = useRoute();
const hasUi = computed(() => route.query.ui !== "0");
return {
colorMode,
currentHostOpaqueRef,
isDesktop,
isMobile,
hasUi,
};
});

View File

@@ -1,32 +1,49 @@
<template>
<div class="home-view">
<UiTitle type="h4">
This helper will generate a basic story component
</UiTitle>
<div>
Choose a component:
<select v-model="componentPath">
<UiCard class="home-view">
<UiCardTitle>Component Story skeleton generator</UiCardTitle>
<div class="row">
Choose a component
<FormSelect v-model="componentPath">
<option value="" />
<option v-for="(component, path) in componentsWithProps" :key="path">
<option v-for="path in componentPaths" :key="path">
{{ path }}
</option>
</select>
<div class="slots">
<label>
Slots names, separated by a comma
<input v-model="slots" />
</label>
<button @click="slots = 'default'">Default</button>
<button @click="slots = ''">Clear</button>
</div>
</FormSelect>
</div>
<CodeHighlight v-if="componentPath" :code="template" />
</div>
<div class="row">
Slot names, separated by comma
<span class="slots">
<FormInput v-model="slots" />
<UiButton @click="slots = 'default'">Default</UiButton>
<UiButton outlined @click="slots = ''">Clear</UiButton>
</span>
</div>
<p v-for="warning in warnings" :key="warning" class="row warning">
<UiIcon :icon="faWarning" />
{{ warning }}
</p>
<CodeHighlight
class="code-highlight"
v-if="componentPath"
:code="template"
/>
</UiCard>
</template>
<script lang="ts" setup>
import CodeHighlight from "@/components/CodeHighlight.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
import FormInput from "@/components/form/FormInput.vue";
import FormSelect from "@/components/form/FormSelect.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { castArray } from "lodash-es";
import { type ComponentOptions, computed, ref, watch } from "vue";
const componentPath = ref("");
@@ -44,10 +61,14 @@ const componentsWithProps = Object.fromEntries(
)
);
const componentPaths = Object.keys(componentsWithProps);
const lines = ref<string[]>([]);
const slots = ref("");
const quote = (str: string) => `'${str}'`;
const camel = (str: string) =>
str.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase());
const paramsToImport = ref(new Set<string>());
const widgetsToImport = ref(new Set<string>());
@@ -61,13 +82,15 @@ const template = computed(() => {
.filter((name) => name !== "");
for (const slotName of slotsNames) {
paramsLines.push(`slot(${slotName === "default" ? "" : quote(slotName)})`);
paramsLines.push(
`slot(${slotName === "default" ? "" : quote(camel(slotName))})`
);
}
for (const slotName of slotsNames) {
paramsLines.push(
`setting(${quote(
`${slotName}SlotContent`
`${camel(slotName)}SlotContent`
)}).preset('Example content for ${slotName} slot').widget(text()).help('Content for ${slotName} slot')`
);
}
@@ -78,7 +101,7 @@ const template = computed(() => {
}
const paramsStr = paramsLines.join(",\n ");
const scriptEndTag = "</" + "script>";
return `<template>
<ComponentStory
v-slot="{ properties, settings }"
@@ -91,8 +114,10 @@ const template = computed(() => {
? `>\n ${slotsNames
.map((name) =>
name === "default"
? `{{ settings.${name}SlotContent }}`
: `<template #${name}>{{ settings.${name}SlotContent }}</template>`
? `{{ settings.${camel(name)}SlotContent }}`
: `<template #${name}>{{ settings.${camel(
name
)}SlotContent }}</template>`
)
.join("\n ")}
</${componentName}>`
@@ -118,10 +143,30 @@ ${
)} } from "@/libs/story/story-widget"`
: ""
}
${"<"}/script>
${scriptEndTag}
`;
});
const warnings = ref(new Set<string>());
const extractTypeFromConstructor = (
ctor: null | (new () => unknown),
propName: string
) => {
if (ctor == null) {
warnings.value.add(
`An unknown type has been detected for prop "${propName}"`
);
return "unknown";
}
if (ctor === Date) {
return "Date";
}
return ctor.name.toLocaleLowerCase();
};
watch(
componentPath,
(path: string) => {
@@ -133,6 +178,7 @@ watch(
slots.value = "";
widgetsToImport.value = new Set();
paramsToImport.value = new Set();
warnings.value = new Set();
lines.value = [];
for (const propName in component.props) {
@@ -147,12 +193,14 @@ watch(
current.push(`default(${quote(prop.default)})`);
}
if (prop.type) {
const type = prop.type();
if (prop.type !== undefined) {
const type = castArray(prop.type)
.map((ctor) => extractTypeFromConstructor(ctor, propName))
.join(" | ");
current.push(
`type(${quote(Array.isArray(type) ? "array" : typeof type)})`
);
if (type !== "unknown") {
current.push(`type(${quote(type)})`);
}
}
const isModel = component.emits?.includes(`update:${propName}`);
@@ -164,17 +212,29 @@ watch(
})`
);
current.push("widget()");
if (!isModel) {
current.push("widget()");
}
lines.value.push(current.join("."));
}
let shouldImportEvent = false;
if (component.emits) {
paramsToImport.value.add("event");
for (const eventName of component.emits) {
lines.value.push(`event("${eventName}")`);
if (eventName.startsWith("update:")) {
continue;
}
shouldImportEvent = true;
lines.value.push(`event(${quote(eventName)})`);
}
}
if (shouldImportEvent) {
paramsToImport.value.add("event");
}
},
{ immediate: true }
);
@@ -185,11 +245,28 @@ watch(
margin: 1rem;
}
.ui-title {
margin-bottom: 1rem;
.slots {
display: inline-flex;
align-items: stretch;
gap: 1rem;
:deep(input) {
height: 100%;
}
}
.slots {
.row {
margin-bottom: 2rem;
font-size: 1.6rem;
}
.warning {
font-size: 1.6rem;
font-weight: 600;
color: var(--color-orange-world-base);
}
.code-highlight {
margin-top: 1rem;
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<PageUnderConstruction />
</template>
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
</script>

View File

@@ -1,34 +1,20 @@
<template>
<div v-if="!isReady">Loading...</div>
<div v-else-if="!isVmRunning">Console is only available for running VMs.</div>
<template v-else-if="vm && vmConsole">
<RemoteConsole
:is-console-available="!isOperationsPending(vm, STOP_OPERATIONS)"
:location="vmConsole.location"
class="remote-console"
/>
<RouterLink
v-if="uiStore.hasUi"
:to="{ query: { ui: '0' } }"
class="open-link"
target="_blank"
>
<UiIcon :icon="faArrowUpRightFromSquare" />
Open in new window
</RouterLink>
</template>
<RemoteConsole
v-else-if="vm && vmConsole"
:location="vmConsole.location"
:is-console-available="!isOperationsPending(vm, STOP_OPERATIONS)"
/>
</template>
<script lang="ts" setup>
import RemoteConsole from "@/components/RemoteConsole.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { isOperationsPending } from "@/libs/utils";
import { useConsoleStore } from "@/stores/console.store";
import { useUiStore } from "@/stores/ui.store";
import { useVmStore } from "@/stores/vm.store";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import { useRoute } from "vue-router";
import RemoteConsole from "@/components/RemoteConsole.vue";
import { useConsoleStore } from "@/stores/console.store";
import { useVmStore } from "@/stores/vm.store";
import { isOperationsPending } from "@/libs/utils";
const STOP_OPERATIONS = [
"shutdown",
@@ -40,7 +26,6 @@ const STOP_OPERATIONS = [
"suspend",
];
const uiStore = useUiStore();
const route = useRoute();
const { isReady: isVmReady, getByUuid: getVmByUuid } = useVmStore().subscribe();
@@ -64,31 +49,3 @@ const vmConsole = computed(() => {
return getConsoleByOpaqueRef(consoleOpaqueRef);
});
</script>
<style lang="postcss" scoped>
.open-link {
display: flex;
align-items: center;
gap: 1rem;
background-color: var(--color-extra-blue-base);
color: var(--color-blue-scale-500);
text-decoration: none;
padding: 1.5rem;
font-size: 1.6rem;
border-radius: 0 0 0 0.8rem;
position: absolute;
top: 8rem;
right: 0;
white-space: nowrap;
transform: translateX(calc(100% - 4.5rem));
transition: transform 0.2s ease-in-out;
&:hover {
transform: translateX(0);
}
}
.remote-console {
height: calc(100% - 8rem);
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<PageUnderConstruction />
</template>
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
</script>

View File

@@ -0,0 +1,7 @@
<template>
<PageUnderConstruction />
</template>
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
</script>

View File

@@ -1,6 +1,7 @@
<template>
<ObjectNotFoundWrapper :is-ready="isReady" :uuid-checker="hasUuid">
<VmHeader v-if="uiStore.hasUi" />
<VmHeader />
<VmTabBar :uuid="vm!.uuid" />
<RouterView />
</ObjectNotFoundWrapper>
</template>
@@ -8,18 +9,16 @@
<script lang="ts" setup>
import ObjectNotFoundWrapper from "@/components/ObjectNotFoundWrapper.vue";
import VmHeader from "@/components/vm/VmHeader.vue";
import VmTabBar from "@/components/vm/VmTabBar.vue";
import { useUiStore } from "@/stores/ui.store";
import { useVmStore } from "@/stores/vm.store";
import { watchEffect } from "vue";
import { whenever } from "@vueuse/core";
import { computed } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const { getByUuid, hasUuid, isReady } = useVmStore().subscribe();
const uiStore = useUiStore();
watchEffect(() => {
uiStore.currentHostOpaqueRef = getByUuid(
route.params.uuid as string
)?.resident_on;
});
const vm = computed(() => getByUuid(route.params.uuid as string));
whenever(vm, (vm) => (uiStore.currentHostOpaqueRef = vm.resident_on));
</script>

View File

@@ -0,0 +1,7 @@
<template>
<PageUnderConstruction />
</template>
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
</script>

View File

@@ -0,0 +1,7 @@
<template>
<PageUnderConstruction />
</template>
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
</script>

View File

@@ -0,0 +1,7 @@
<template>
<PageUnderConstruction />
</template>
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
</script>

View File

@@ -0,0 +1,7 @@
<template>
<PageUnderConstruction />
</template>
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
</script>

View File

@@ -14,6 +14,15 @@ const formatId = timestamp => timestamp.toString(36).padStart(9, '0')
const noop = Function.prototype
// Create a serializable object from an error.
const serializeError = error => ({
...error, // Copy enumerable properties.
code: error.code,
message: error.message,
name: error.name,
stack: error.stack,
})
export default class Tasks extends EventEmitter {
// contains consolidated logs of all live and finished tasks
#store
@@ -27,6 +36,12 @@ export default class Tasks extends EventEmitter {
this.#tasks.delete(id)
},
onTaskUpdate: async taskLog => {
// Error objects are not JSON-ifiable by default
const { result } = taskLog
if (result instanceof Error && result.toJSON === undefined) {
taskLog.result = serializeError(result)
}
try {
const { $root } = taskLog

View File

@@ -21,7 +21,7 @@
"dependencies": {
"@vates/event-listeners-manager": "^1.0.1",
"@vates/parse-duration": "^0.1.1",
"@vates/task": "^0.1.1",
"@vates/task": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"acme-client": "^5.0.0",
"app-conf": "^2.3.0",

View File

@@ -27,7 +27,7 @@
"@babel/core": "^7.7.4",
"@babel/preset-env": "^7.7.4",
"cross-env": "^7.0.2",
"rimraf": "^4.1.1"
"rimraf": "^5.0.1"
},
"dependencies": {
"@vates/read-chunk": "^1.1.1"

View File

@@ -1,7 +1,6 @@
import Disposable from 'promise-toolbox/Disposable'
import fromCallback from 'promise-toolbox/fromCallback'
import { asyncMap } from '@xen-orchestra/async-map'
import { Backup } from '@xen-orchestra/backups/Backup.js'
import { compose } from '@vates/compose'
import { createLogger } from '@xen-orchestra/log'
import { decorateMethodsWith } from '@vates/decorate-with'
@@ -10,6 +9,7 @@ import { defer } from 'golike-defer'
import { DurablePartition } from '@xen-orchestra/backups/DurablePartition.js'
import { execFile } from 'child_process'
import { formatVmBackups } from '@xen-orchestra/backups/formatVmBackups.js'
import { instantiateBackupJob } from '@xen-orchestra/backups/backupJob.js'
import { ImportVmBackup } from '@xen-orchestra/backups/ImportVmBackup.js'
import { JsonRpcError } from 'json-rpc-protocol'
import { Readable } from 'stream'
@@ -52,7 +52,7 @@ export default class Backups {
const config = app.config.get('backups')
if (config.disableWorkers) {
const { recordToXapi, remotes, xapis, ...rest } = params
return new Backup({
return instantiateBackupJob({
...rest,
config,

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "@xen-orchestra/proxy",
"version": "0.26.21",
"version": "0.26.23",
"license": "AGPL-3.0-or-later",
"description": "XO Proxy used to remotely execute backup jobs",
"keywords": [
@@ -32,7 +32,7 @@
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.36.0",
"@xen-orchestra/backups": "^0.36.1",
"@xen-orchestra/fs": "^3.3.4",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/mixin": "^0.1.0",
@@ -60,7 +60,7 @@
"source-map-support": "^0.5.16",
"stoppable": "^1.0.6",
"xdg-basedir": "^5.1.0",
"xen-api": "^1.3.0",
"xen-api": "^1.3.1",
"xo-common": "^0.8.0"
},
"devDependencies": {

View File

@@ -43,14 +43,14 @@
"pw": "^0.0.4",
"xdg-basedir": "^4.0.0",
"xo-lib": "^0.11.1",
"xo-vmdk-to-vhd": "^2.5.3"
"xo-vmdk-to-vhd": "^2.5.4"
},
"devDependencies": {
"@babel/cli": "^7.0.0",
"@babel/core": "^7.0.0",
"@babel/preset-env": "^7.0.0",
"cross-env": "^7.0.2",
"rimraf": "^4.1.1"
"rimraf": "^5.0.1"
},
"scripts": {
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",

View File

@@ -4,7 +4,7 @@
"version": "0.2.2",
"name": "@xen-orchestra/vmware-explorer",
"dependencies": {
"@vates/task": "^0.1.1",
"@vates/task": "^0.1.2",
"@vates/read-chunk": "^1.1.1",
"lodash": "^4.17.21",
"node-fetch": "^3.3.0",

View File

@@ -15,7 +15,7 @@
"node": ">=14"
},
"peerDependencies": {
"xen-api": "^1.3.0"
"xen-api": "^1.3.1"
},
"scripts": {
"postversion": "npm publish --access public",

View File

@@ -1,9 +1,46 @@
# ChangeLog
## **5.82.0** (2023-04-28)
## **5.82.2** (2023-05-17)
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
### Bug fixes
- [New/VM] Fix stuck Cloud Config import ([GitHub comment](https://github.com/vatesfr/xen-orchestra/issues/5896#issuecomment-1465253774))
### Released packages
- xen-api 1.3.1
- @xen-orchestra/proxy 0.26.23
- xo-server 5.114.2
## **5.82.1** (2023-05-12)
### Enhancements
- [Plugins] Clicking on a plugin name now filters out other plugins
### Bug fixes
- [Host/Network] Fix IP configuration not working with empty fields
- [Import/VM/From VMware] Fix `Property description must be an object: undefined` [Forum#61834](https://xcp-ng.org/forum/post/61834) [Forum#61900](https://xcp-ng.org/forum/post/61900)
- [Import/VM/From VMware] Fix `Cannot read properties of undefined (reading 'stream')` [Forum#59879](https://xcp-ng.org/forum/post/59879) (PR [#6825](https://github.com/vatesfr/xen-orchestra/pull/6825))
- [OVA export] Fix major memory leak which may lead to xo-server crash [Forum#56051](https://xcp-ng.org/forum/post/56051) (PR [#6800](https://github.com/vatesfr/xen-orchestra/pull/6800))
- [VM] Fix `VBD_IS_EMPTY` error when converting to template [Forum#61653](https://xcp-ng.org/forum/post/61653) (PR [#6808](https://github.com/vatesfr/xen-orchestra/pull/6808))
- [New/Network] Fix `invalid parameter error` when not providing a VLAN [Forum#62090](https://xcp-ng.org/forum/post/62090) (PR [#6829](https://github.com/vatesfr/xen-orchestra/pull/6829))
- [Backup/Health check] Fix `task has already ended` error during a healthcheck in continous replication [Forum#62073](https://xcp-ng.org/forum/post/62073) (PR [#6830](https://github.com/vatesfr/xen-orchestra/pull/6830))
### Released packages
- @vates/task 0.1.2
- xo-vmdk-to-vhd 2.5.4
- @xen-orchestra/backups 0.36.1
- @xen-orchestra/proxy 0.26.22
- xo-server 5.114.1
- xo-web 5.117.1
## **5.82.0** (2023-04-28)
### Highlights
- [Host] Smart reboot: suspend resident VMs, restart host and resume VMs [#6750](https://github.com/vatesfr/xen-orchestra/issues/6750) (PR [#6795](https://github.com/vatesfr/xen-orchestra/pull/6795))

View File

@@ -7,15 +7,14 @@
> Users must be able to say: “Nice enhancement, I'm eager to test it”
- [Plugins] Clicking on a plugin name now filters out other plugins
- [Proxy] Make proxy address editable (PR [#6816](https://github.com/vatesfr/xen-orchestra/pull/6816))
- [Home/Host] Displays a warning for hosts with HVM disabled [#6823](https://github.com/vatesfr/xen-orchestra/issues/6823) (PR [#6834](https://github.com/vatesfr/xen-orchestra/pull/6834))
### Bug fixes
> Users must be able to say: “I had this issue, happy to know it's fixed”
- [Host/Network] Fix IP configuration not working with empty fields
- [Import/VM/From VMware] Fix `Property description must be an object: undefined` [Forum#61834](https://xcp-ng.org/forum/post/61834) [Forum#61900](https://xcp-ng.org/forum/post/61900)
- [Import/VM/From VMware] Fix `Cannot read properties of undefined (reading 'stream')` [Forum#59879](https://xcp-ng.org/forum/post/59879) (PR [#6825](https://github.com/vatesfr/xen-orchestra/pull/6825))
- [Sorted table] In collapsed actions, a spinner is displayed during the action time (PR [#6831](https://github.com/vatesfr/xen-orchestra/pull/6831))
### Packages to release
@@ -33,8 +32,8 @@
<!--packages-start-->
- @vates/task patch
- xo-server minor
- @xen-orchestra/mixins patch
- xo-cli minor
- xo-web minor
<!--packages-end-->

View File

@@ -336,5 +336,5 @@ useSudo = true
You need to configure `sudo` to allow the user of your choice to run mount/umount commands without asking for a password. Depending on your operating system / sudo version, the location of this configuration may change. Regardless, you can use:
```
username ALL=(root)NOPASSWD: /bin/mount, /bin/umount
username ALL=(root)NOPASSWD: /bin/mount, /bin/umount, /bin/findmnt
```

View File

@@ -14,7 +14,7 @@
"eslint-config-standard-jsx": "^11.0.0-1",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-n": "^15.1.0",
"eslint-plugin-n": "^16.0.0",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-react": "^7.21.5",
"exec-promise": "^0.7.0",
@@ -96,7 +96,7 @@
"prepare": "husky install",
"prettify": "prettier --ignore-path .gitignore --ignore-unknown --write .",
"test": "npm run test-lint && npm run test-unit",
"test-integration": "jest \".integ\\.spec\\.js$\"",
"test-integration": "jest \".integ\\.spec\\.js$\" && scripts/run-script.js --parallel test-integration",
"test-lint": "eslint --ignore-path .gitignore --ignore-pattern packages/xo-web .",
"test-unit": "jest \"^(?!.*\\.integ\\.spec\\.js$)\" && scripts/run-script.js --bail test"
},

View File

@@ -3,9 +3,9 @@
const { beforeEach, afterEach, test } = require('test')
const execa = require('execa')
const rimraf = require('rimraf')
const tmp = require('tmp')
const { pFromCallback } = require('promise-toolbox')
const { rimraf } = require('rimraf')
const command = require('./commands/info')

View File

@@ -35,11 +35,11 @@
},
"scripts": {
"postversion": "npm publish",
"test": "node--test"
"test-integration": "node--test *.integ.js"
},
"devDependencies": {
"execa": "^4.0.0",
"rimraf": "^4.1.1",
"rimraf": "^5.0.1",
"test": "^3.2.1",
"tmp": "^0.2.1"
}

View File

@@ -2,11 +2,11 @@
/* eslint-env jest */
const rimraf = require('rimraf')
const tmp = require('tmp')
const fs = require('fs-extra')
const { getSyncedHandler } = require('@xen-orchestra/fs')
const { Disposable, pFromCallback } = require('promise-toolbox')
const { rimraf } = require('rimraf')
const { openVhd } = require('../index')
const { checkFile, createRandomFile, convertFromRawToVhd, createRandomVhdDirectory } = require('../tests/utils')

View File

@@ -2,11 +2,11 @@
/* eslint-env jest */
const rimraf = require('rimraf')
const tmp = require('tmp')
const fs = require('fs-extra')
const { getSyncedHandler } = require('@xen-orchestra/fs')
const { Disposable, pFromCallback } = require('promise-toolbox')
const { rimraf } = require('rimraf')
const { openVhd, VhdDirectory } = require('../')
const { createRandomFile, convertFromRawToVhd, convertToVhdDirectory } = require('../tests/utils')

View File

@@ -5,11 +5,11 @@
const execa = require('execa')
const fs = require('fs-extra')
const getStream = require('get-stream')
const rimraf = require('rimraf')
const tmp = require('tmp')
const { getSyncedHandler } = require('@xen-orchestra/fs')
const { Disposable, pFromCallback } = require('promise-toolbox')
const { randomBytes } = require('crypto')
const { rimraf } = require('rimraf')
const { VhdFile } = require('./VhdFile')
const { openVhd } = require('../openVhd')

View File

@@ -2,10 +2,10 @@
/* eslint-env jest */
const rimraf = require('rimraf')
const tmp = require('tmp')
const { Disposable, pFromCallback } = require('promise-toolbox')
const { getSyncedHandler } = require('@xen-orchestra/fs')
const { rimraf } = require('rimraf')
const { SECTOR_SIZE, PLATFORMS } = require('../_constants')
const { createRandomFile, convertFromRawToVhd } = require('../tests/utils')

View File

@@ -2,10 +2,10 @@
/* eslint-env jest */
const rimraf = require('rimraf')
const tmp = require('tmp')
const { getSyncedHandler } = require('@xen-orchestra/fs')
const { Disposable, pFromCallback } = require('promise-toolbox')
const { rimraf } = require('rimraf')
const { isVhdAlias, resolveVhdAlias } = require('./aliases')
const { ALIAS_MAX_PATH_LENGTH } = require('./_constants')

View File

@@ -4,12 +4,12 @@
const execa = require('execa')
const fs = require('fs-extra')
const rimraf = require('rimraf')
const getStream = require('get-stream')
const tmp = require('tmp')
const { createReadStream, createWriteStream } = require('fs')
const { pFromCallback } = require('promise-toolbox')
const { pipeline } = require('readable-stream')
const { rimraf } = require('rimraf')
const createVhdStreamWithLength = require('./createVhdStreamWithLength.js')
const { FOOTER_SIZE } = require('./_constants')

View File

@@ -3,10 +3,10 @@
/* eslint-env jest */
const fs = require('fs-extra')
const rimraf = require('rimraf')
const tmp = require('tmp')
const { getSyncedHandler } = require('@xen-orchestra/fs')
const { pFromCallback, Disposable } = require('promise-toolbox')
const { rimraf } = require('rimraf')
const { VhdFile, chainVhd, openVhd, VhdAbstract } = require('./index')
const { mergeVhdChain } = require('./merge')

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