Compare commits

..

55 Commits

Author SHA1 Message Date
Florent Beauchamp
ca8d6b4885 demo 2024-02-21 13:35:48 +00:00
Florent Beauchamp
b89195eb80 fix(backups/IncrementalRemote): ensure chaining is ok and mutualize code with IncrementalXapi 2024-02-21 10:27:56 +01:00
Florent Beauchamp
822cdc3fb8 refactor(backups/IncrementalRemoteWriter): reuse parent path from checkBaseVdis 2024-02-21 10:27:56 +01:00
Florent Beauchamp
c7b5b715a3 refactor(backups/checkBaseVdi): use uuid, don't check vhd multiple times 2024-02-21 10:27:56 +01:00
Florent Beauchamp
56b427c09c fix(vhd-lib/VhdSynthetic): compression type computation 2024-02-21 10:27:56 +01:00
Mathieu
0e45c52bbc feat(lite/xapi-stats): handle new format (#7383)
Similar to 757a8915d9

Starting from XAPI 23.31, stats are in valid JSON but numbers are encoded as strings.
2024-02-20 17:55:57 +01:00
Mathieu
4fd2b91fc4 feat(xo-web/SizeInput): added 'TiB' and 'PiB' units (#7382) 2024-02-20 17:43:21 +01:00
Florent BEAUCHAMP
7890320a7d fix(xo-server/import): error during import of last snapshot of running VM (#7370)
From zammad#21710

Introduced by 2d047c4fef
2024-02-20 17:39:39 +01:00
Julien Fontanet
1718649e0c feat(xo-server/vm.$container): points to host if VDI on local SR
Fixes https://xcp-ng.org/forum/post/71769
2024-02-20 16:49:53 +01:00
Julien Fontanet
7fc5d62ca9 feat(xo-server/rest-api): export hosts' SMT status
Fixes https://xcp-ng.org/forum/post/71374
2024-02-20 16:33:33 +01:00
Julien Fontanet
eedaca0195 feat(xo-server/remotes): detect, log and fix incorrect params (#7343) 2024-02-16 16:23:06 +01:00
Julien Fontanet
9ffa52cc01 docs(xoa): manual network config 2024-02-16 11:25:34 +01:00
Julien Fontanet
e9a23755b6 test(fs/path/normalizePath): test relative paths handling
Related to 5712f29a5
2024-02-15 10:10:44 +01:00
Julien Fontanet
5712f29a58 fix(vhd-lib/chainVhd): correctly handle relative paths 2024-02-15 09:14:32 +01:00
Julien Fontanet
509ebf900e fix(fs/path/relativeFromFile): correctly handle relative paths 2024-02-15 09:13:10 +01:00
Julien Fontanet
757a8915d9 feat(xo-server/xapi-stats): handle new format
Starting from XAPI 23.31, stats are in valid JSON but numbers are encoded as strings.
2024-02-14 16:14:43 +01:00
Thierry Goettelmann
35c660dbf6 feat(xo-stack): add @core alias to import Core from Web and Lite (#7375) 2024-02-14 14:43:23 +01:00
Julien Fontanet
f23fd69e7e fix(xapi/VIF_create): fetch power_state and MTU in parallel 2024-02-14 11:48:07 +01:00
Julien Fontanet
39c10a7197 fix(xapi/VIF_create): explicit error when no allowed devices
Related to #7380
2024-02-14 11:48:07 +01:00
Julien Fontanet
7a1bc16468 fix: respect logger method signature
This is a minor fix that should not have major impacts.

It's not necessary to release impacted packages.
2024-02-13 17:38:03 +01:00
Julien Fontanet
93dd1a63da docs(log): document method signature 2024-02-13 17:35:58 +01:00
Florent Beauchamp
b4e1064914 fix(backups): _isAlreadyTransferred is async
This leads to a retransfer and a EEXIST error while writing the metadata.

It can happen when a mirror transfer to multiple remotes, fails on one remote and is restarted/resumed.
2024-02-13 16:03:45 +01:00
Florent Beauchamp
810cdc1a77 fix(backups): really skip already transferred backups 2024-02-13 16:03:45 +01:00
Julien Fontanet
1023131828 chore: update dev deps 2024-02-12 20:47:05 +01:00
Smultar
e2d83324ac chore: add name and version to root package.json (#7372)
Fixes #7371
2024-02-12 16:59:50 +01:00
Julien Fontanet
7cea445c21 fix(xo-web/remotes): don't merge all properties into url
Related to #7343

Introduced by fb1bf6a1e7
2024-02-12 14:51:04 +01:00
Julien Fontanet
b5d9d9a9e1 fix(xo-server-audit): ignore tag.getAllConfigured
Introduced by 25e270edb4
2024-02-12 10:58:06 +01:00
Julien Fontanet
3a4e9b8f8e chore(xo-web/config): remove unused computeds
Introduced by 01302d7a60
2024-02-12 10:55:58 +01:00
Julien Fontanet
92efd28b33 fix(xo-web/config): sort backups from newest to oldest
Introduced by 01302d7a60
2024-02-12 10:55:26 +01:00
Julien Fontanet
a2c36c0832 feat(xo-server): add robots.txt
Fixes zammad#21489
2024-02-09 11:25:06 +01:00
Florent BEAUCHAMP
2eb49cfdf1 feat: release 5.91.2 (#7367) 2024-02-09 11:10:59 +01:00
Florent BEAUCHAMP
ba9d4d4bb5 feat: technical release (#7365) 2024-02-09 10:09:09 +01:00
b-Nollet
18dea2f2fe fix(xo-server-load-balancer): create simple plan as in config (#7358)
Previously, a density plan was created when simple plan was selected in load-balancer configuration.
2024-02-08 18:14:08 +01:00
Julien Fontanet
70c51227bf feat(xo-server/rest-api): expose messages
Fixes zammad#21415
2024-02-08 11:25:05 +01:00
Julien Fontanet
e162fd835b feat(xo-server/rest-api): add /groups/:id/users and /users/:id/groups collections
Fixes https://xcp-ng.org/forum/post/70500
2024-02-08 11:23:17 +01:00
Julien Fontanet
bcdcfbf20b feat(xo-server/rest-api): add groups collection
See https://xcp-ng.org/forum/post/70500
2024-02-08 11:23:17 +01:00
Julien Fontanet
a6e93c895c chore(xo-server/rest-api): unify collections handling 2024-02-08 11:23:17 +01:00
Julien Fontanet
5c4f907358 chore(xo-server/rest-api): move :object handling to the collection 2024-02-08 09:56:04 +01:00
Julien Fontanet
e19dbc06fe chore(xo-server/rest-api): uniformize collections creation 2024-02-08 09:06:53 +01:00
Julien Fontanet
287378f9c6 fix(xo-web): changing items per page should select page 1
Fixes #7350
2024-02-07 16:03:53 +01:00
b-Nollet
83a94eefd6 fix(xo-server-load-balancer): error during optimize (#7362)
Introduced by d949112

Fixes #7359
2024-02-07 15:48:10 +01:00
Julien Fontanet
92fc19e2e3 fix(xo-server/api): never log proxy.getApplianceUpdaterState
Due to xo-web's subscription, there can be a lot of errors in case of XO Proxy failures.
2024-02-07 11:42:21 +01:00
Julien Fontanet
521d31ac84 feat(xo-server/logs-cli): display number of deleted entries 2024-02-07 11:42:21 +01:00
Florent BEAUCHAMP
2b3ccb4b0e fix(xo-server/vm.importFromEsxi): userdevice is a string (#7361)
Introduced by 59cc418973

From zammad#82017 and other tickets
2024-02-07 10:24:50 +01:00
Julien Fontanet
2498a4f47c feat: release 5.91.1 2024-02-06 17:18:47 +01:00
Julien Fontanet
dd61feeaf3 feat(xo-server): 5.135.1 2024-02-06 16:41:06 +01:00
Julien Fontanet
7851f8c196 feat(@xen-orchestra/xva): 1.0.2 2024-02-06 16:41:05 +01:00
Julien Fontanet
404a764821 feat(@xen-orchestra/immutable-backups): 1.0.1 2024-02-06 16:40:52 +01:00
Florent Beauchamp
59cc418973 fix(xva): disk order import 2024-02-06 15:00:08 +01:00
Florent Beauchamp
bc00551cb3 fix(xva): off by one
if last block is not already written we must write it, not the next bloxk
2024-02-06 15:00:08 +01:00
Florent Beauchamp
4d24248ea2 fix(xva): missing await 2024-02-06 15:00:08 +01:00
Florent BEAUCHAMP
5c731fd56e fix(xva): align block size 2024-02-06 15:00:08 +01:00
Florent BEAUCHAMP
79abb97b1f fix(xva): last block path
the last block may be added by a different code path where the block counter wasn't padded, leading to trying to allocate a few Ettabytes
2024-02-06 15:00:08 +01:00
Florent BEAUCHAMP
3314ba6e08 fix(xva): write block from time to time to handle timeout
when the source VM is very sparse, a long time can pass between write to xapi, triggering a timeout
2024-02-06 15:00:08 +01:00
Florent Beauchamp
0fe8f8cac3 fix(xo-server/migrate-vms): use thin mode for vhdraw
when importing a snapshot, the parent (raw) does not need to go through a full read
to generate a thin BAT
2024-02-06 15:00:08 +01:00
69 changed files with 2208 additions and 1651 deletions

View File

@@ -65,10 +65,11 @@ module.exports = {
typescript: true,
'eslint-import-resolver-custom-alias': {
alias: {
'@core': '../web-core/lib',
'@': './src',
},
extensions: ['.ts'],
packages: ['@xen-orchestra/lite'],
packages: ['@xen-orchestra/lite', '@xen-orchestra/web'],
},
},
},

View File

@@ -160,10 +160,10 @@ export class ImportVmBackup {
// update the stream with the negative vhd stream
stream = await negativeVhd.stream()
vdis[vdiRef].baseVdi = snapshotCandidate
} catch (err) {
} catch (error) {
// can be a broken VHD chain, a vhd chain with a key backup, ....
// not an irrecuperable error, don't dispose parentVhd, and fallback to full restore
warn(`can't use differential restore`, err)
warn(`can't use differential restore`, { error })
disposableDescendants?.dispose()
}
}

View File

@@ -191,13 +191,14 @@ export class RemoteAdapter {
// check if we will be allowed to merge a a vhd created in this adapter
// with the vhd at path `path`
async isMergeableParent(packedParentUid, path) {
return await Disposable.use(openVhd(this.handler, path), vhd => {
return await Disposable.use(VhdSynthetic.fromVhdChain(this.handler, path), vhd => {
// this baseUuid is not linked with this vhd
if (!vhd.footer.uuid.equals(packedParentUid)) {
return false
}
const isVhdDirectory = vhd instanceof VhdDirectory
// check if all the chain is composed of vhd directory
const isVhdDirectory = vhd.checkVhdsClass(VhdDirectory)
return isVhdDirectory
? this.useVhdDirectory() && this.#getCompressionType() === vhd.compressionType
: !this.useVhdDirectory()

View File

@@ -141,6 +141,7 @@ export class Task {
}
#log(event, props) {
console.log({taskId: this.#id,event})
this.#onLog({
...props,
event,

View File

@@ -88,49 +88,89 @@ export const VmsXapi = class VmsXapiBackupRunner extends Abstract {
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 IncrementalXapi(opts)
} else {
if (job.mode === 'full') {
vmBackup = new FullXapi(opts)
} else {
throw new Error(`Job mode ${job.mode} not implemented`)
}
}
return vmBackup.run()
})
}),
error =>
runTask(taskStart, () => {
throw error
})
)
const task = new Task(taskStart)
let nbRun = 0
console.log('runonce', vmUuid)
// ensure all the eecution are run in the same task
const runOnce = async ()=> {
nbRun ++
console.log('Will run backup ', vmUuid, nbRun)
return this._getRecord('VM', vmUuid).then(
disposableVm => Disposable.use(disposableVm, async vm => {
taskStart.data.name_label = vm.name_label
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 IncrementalXapi(opts)
} else {
if (job.mode === 'full') {
vmBackup = new FullXapi(opts)
} else {
throw new Error(`Job mode ${job.mode} not implemented`)
}
}
const res = await vmBackup.run()
console.log(' backup run successfully ', vmUuid, res)
return res
}))
}
// ensure the same task is reused
return task.wrapFn(runOnce)
}
let toHandle = []
console.log('prepare to run ')
for(const vmUuid of vmIds){
// prepare a collection of task to bound task to run
toHandle.push( handleVm(vmUuid))
}
console.log({toHandle})
for(let i=0; i < 4 && toHandle.length >0; i++){
console.log('RUN ', i)
const currentRun = [...toHandle]
toHandle = []
await asyncMapSettled(currentRun, async fn=>{
console.log('got fn ', fn)
try{
await fn()
console.log('run done')
}catch(error){
console.log('will retry ')
toHandle.push(fn)
}
})
}
const { concurrency } = settings
await asyncMapSettled(vmIds, concurrency === 0 ? handleVm : limitConcurrency(concurrency)(handleVm))
if(toHandle.length > 0){
console.log('LAST RUN ')
// last run will really fail this time
await asyncMapSettled(toHandle, fn=>fn())
}
}
)
}

View File

@@ -2,6 +2,7 @@ import { asyncEach } from '@vates/async-each'
import { decorateMethodsWith } from '@vates/decorate-with'
import { defer } from 'golike-defer'
import assert from 'node:assert'
import * as UUID from 'uuid'
import isVhdDifferencingDisk from 'vhd-lib/isVhdDifferencingDisk.js'
import mapValues from 'lodash/mapValues.js'
@@ -9,11 +10,48 @@ import { AbstractRemote } from './_AbstractRemote.mjs'
import { forkDeltaExport } from './_forkDeltaExport.mjs'
import { IncrementalRemoteWriter } from '../_writers/IncrementalRemoteWriter.mjs'
import { Task } from '../../Task.mjs'
import { Disposable } from 'promise-toolbox'
import { openVhd } from 'vhd-lib'
import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
class IncrementalRemoteVmBackupRunner extends AbstractRemote {
_getRemoteWriter() {
return IncrementalRemoteWriter
}
async _selectBaseVm(metadata) {
// for each disk , get the parent
const baseUuidToSrcVdi = new Map()
// no previous backup for a base( =key) backup
if (metadata.isBase) {
return
}
await asyncEach(Object.entries(metadata.vdis), async ([id, vdi]) => {
const isDifferencing = metadata.isVhdDifferencing[`${id}.vhd`]
if (isDifferencing) {
const vmDir = getVmBackupDir(metadata.vm.uuid)
const path = `${vmDir}/${metadata.vhds[id]}`
// don't catch error : we can't recover if the source vhd are missing
await Disposable.use(openVhd(this._sourceRemoteAdapter._handler, path), vhd => {
baseUuidToSrcVdi.set(UUID.stringify(vhd.header.parentUuid), vdi.$snapshot_of$uuid)
})
}
})
const presentBaseVdis = new Map(baseUuidToSrcVdi)
await this._callWriters(
writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis),
'writer.checkBaseVdis()',
false
)
// check if the parent vdi are present in all the remotes
baseUuidToSrcVdi.forEach((srcVdiUuid, baseUuid) => {
if (!presentBaseVdis.has(baseUuid)) {
throw new Error(`Missing vdi ${baseUuid} which is a base for a delta`)
}
})
// yeah , let's go
}
async _run($defer) {
const transferList = await this._computeTransferList(({ mode }) => mode === 'delta')
await this._callWriters(async writer => {
@@ -26,7 +64,7 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
if (transferList.length > 0) {
for (const metadata of transferList) {
assert.strictEqual(metadata.mode, 'delta')
await this._selectBaseVm(metadata)
await this._callWriters(writer => writer.prepare({ isBase: metadata.isBase }), 'writer.prepare()')
const incrementalExport = await this._sourceRemoteAdapter.readIncrementalVmBackup(metadata, undefined, {
useChain: false,
@@ -50,6 +88,17 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
}),
'writer.transfer()'
)
// this will update parent name with the needed alias
await this._callWriters(
writer =>
writer.updateUuidAndChain({
isVhdDifferencing,
timestamp: metadata.timestamp,
vdis: incrementalExport.vdis,
}),
'writer.updateUuidAndChain()'
)
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
// for healthcheck
this._tags = metadata.vm.tags

View File

@@ -78,6 +78,18 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
'writer.transfer()'
)
// we want to control the uuid of the vhd in the chain
// and ensure they are correctly chained
await this._callWriters(
writer =>
writer.updateUuidAndChain({
isVhdDifferencing,
timestamp,
vdis: deltaExport.vdis,
}),
'writer.updateUuidAndChain()'
)
this._baseVm = exportedVm
if (baseVm !== undefined) {
@@ -133,7 +145,7 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
])
const srcVdi = srcVdis[snapshotOf]
if (srcVdi !== undefined) {
baseUuidToSrcVdi.set(baseUuid, srcVdi)
baseUuidToSrcVdi.set(baseUuid, srcVdi.uuid)
} else {
debug('ignore snapshot VDI because no longer present on VM', {
vdi: baseUuid,
@@ -154,18 +166,18 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
}
const fullVdisRequired = new Set()
baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
baseUuidToSrcVdi.forEach((srcVdiUuid, baseUuid) => {
if (presentBaseVdis.has(baseUuid)) {
debug('found base VDI', {
base: baseUuid,
vdi: srcVdi.uuid,
vdi: srcVdiUuid,
})
} else {
debug('missing base VDI', {
base: baseUuid,
vdi: srcVdi.uuid,
vdi: srcVdiUuid,
})
fullVdisRequired.add(srcVdi.uuid)
fullVdisRequired.add(srcVdiUuid)
}
})

View File

@@ -64,6 +64,11 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
}
await Task.run({ name: 'transfer' }, async () => {
console.log('run ')
if(Math.random() < 0.8){
throw new Error('NOPE')
}
console.log('OK ')
await adapter.outputStream(dataFilename, stream, {
maxStreamLength,
streamLength,

View File

@@ -1,17 +1,15 @@
import assert from 'node:assert'
import mapValues from 'lodash/mapValues.js'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
import { asyncEach } from '@vates/async-each'
import { asyncMap } from '@xen-orchestra/async-map'
import { chainVhd, checkVhdChain, openVhd, VhdAbstract } from 'vhd-lib'
import { chainVhd, openVhd } from 'vhd-lib'
import { createLogger } from '@xen-orchestra/log'
import { decorateClass } from '@vates/decorate-with'
import { defer } from 'golike-defer'
import { dirname } from 'node:path'
import { dirname, basename } from 'node:path'
import { formatFilenameDate } from '../../_filenameDate.mjs'
import { getOldEntries } from '../../_getOldEntries.mjs'
import { TAG_BASE_DELTA } from '../../_incrementalVm.mjs'
import { Task } from '../../Task.mjs'
import { MixinRemoteWriter } from './_MixinRemoteWriter.mjs'
@@ -23,42 +21,45 @@ import { Disposable } from 'promise-toolbox'
const { warn } = createLogger('xo:backups:DeltaBackupWriter')
export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrementalWriter) {
#parentVdiPaths
#vhds
async checkBaseVdis(baseUuidToSrcVdi) {
this.#parentVdiPaths = {}
const { handler } = this._adapter
const adapter = this._adapter
const vdisDir = `${this._vmBackupDir}/vdis/${this._job.id}`
await asyncMap(baseUuidToSrcVdi, async ([baseUuid, srcVdi]) => {
let found = false
await asyncMap(baseUuidToSrcVdi, async ([baseUuid, srcVdiUuid]) => {
let parentDestPath
const vhdDir = `${vdisDir}/${srcVdiUuid}`
try {
const vhds = await handler.list(`${vdisDir}/${srcVdi.uuid}`, {
const vhds = await handler.list(vhdDir, {
filter: _ => _[0] !== '.' && _.endsWith('.vhd'),
ignoreMissing: true,
prependDir: true,
})
const packedBaseUuid = packUuid(baseUuid)
await asyncMap(vhds, async path => {
try {
await checkVhdChain(handler, path)
// Warning, this should not be written as found = found || await adapter.isMergeableParent(packedBaseUuid, path)
//
// since all the checks of a path are done in parallel, found would be containing
// only the last answer of isMergeableParent which is probably not the right one
// this led to the support tickets https://help.vates.fr/#ticket/zoom/4751 , 4729, 4665 and 4300
// the last one is probably the right one
const isMergeable = await adapter.isMergeableParent(packedBaseUuid, path)
found = found || isMergeable
for (let i = vhds.length - 1; i >= 0 && parentDestPath === undefined; i--) {
const path = vhds[i]
try {
if (await adapter.isMergeableParent(packedBaseUuid, path)) {
parentDestPath = path
}
} catch (error) {
warn('checkBaseVdis', { error })
await ignoreErrors.call(VhdAbstract.unlink(handler, path))
}
})
}
} catch (error) {
warn('checkBaseVdis', { error })
}
if (!found) {
// no usable parent => the runner will have to decide to fall back to a full or stop backup
if (parentDestPath === undefined) {
baseUuidToSrcVdi.delete(baseUuid)
} else {
this.#parentVdiPaths[vhdDir] = parentDestPath
}
})
}
@@ -123,6 +124,44 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
}
}
async updateUuidAndChain({ isVhdDifferencing, vdis }) {
assert.notStrictEqual(
this.#vhds,
undefined,
'_transfer must be called before updateUuidAndChain for incremental backups'
)
const parentVdiPaths = this.#parentVdiPaths
const { handler } = this._adapter
const vhds = this.#vhds
await asyncEach(Object.entries(vdis), async ([id, vdi]) => {
const isDifferencing = isVhdDifferencing[`${id}.vhd`]
const path = `${this._vmBackupDir}/${vhds[id]}`
if (isDifferencing) {
assert.notStrictEqual(
parentVdiPaths,
'checkbasevdi must be called before updateUuidAndChain for incremental backups'
)
const parentPath = parentVdiPaths[dirname(path)]
// we are in a incremental backup
// we already computed the chain in checkBaseVdis
assert.notStrictEqual(parentPath, undefined, 'A differential VHD must have a parent')
// forbid any kind of loop
assert.ok(basename(parentPath) < basename(path), `vhd must be sorted to be chained`)
await chainVhd(handler, parentPath, handler, path)
}
// set the correct UUID in the VHD if needed
await Disposable.use(openVhd(handler, path), async vhd => {
if (!vhd.footer.uuid.equals(packUuid(vdi.uuid))) {
vhd.footer.uuid = packUuid(vdi.uuid)
await vhd.readBlockAllocationTable() // required by writeFooter()
await vhd.writeFooter()
}
})
})
}
async _deleteOldEntries() {
const adapter = this._adapter
const oldEntries = this._oldEntries
@@ -141,14 +180,10 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
const jobId = job.id
const handler = adapter.handler
let metadataContent = await this._isAlreadyTransferred(timestamp)
if (metadataContent !== undefined) {
// @todo : should skip backup while being vigilant to not stuck the forked stream
Task.info('This backup has already been transfered')
}
const basename = formatFilenameDate(timestamp)
const vhds = mapValues(
// update this.#vhds before eventually skipping transfer, so that
// updateUuidAndChain has all the mandatory data
const vhds = (this.#vhds = mapValues(
deltaExport.vdis,
vdi =>
`vdis/${jobId}/${
@@ -158,7 +193,15 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
vdi.uuid
: vdi.$snapshot_of$uuid
}/${adapter.getVhdFileName(basename)}`
)
))
let metadataContent = await this._isAlreadyTransferred(timestamp)
if (metadataContent !== undefined) {
// skip backup while being vigilant to not stuck the forked stream
Task.info('This backup has already been transfered')
Object.values(deltaExport.streams).forEach(stream => stream.destroy())
return { size: 0 }
}
metadataContent = {
isVhdDifferencing,
@@ -174,38 +217,13 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
vm,
vmSnapshot,
}
const { size } = await Task.run({ name: 'transfer' }, async () => {
let transferSize = 0
await asyncEach(
Object.entries(deltaExport.vdis),
async ([id, vdi]) => {
Object.keys(deltaExport.vdis),
async id => {
const path = `${this._vmBackupDir}/${vhds[id]}`
const isDifferencing = isVhdDifferencing[`${id}.vhd`]
let parentPath
if (isDifferencing) {
const vdiDir = dirname(path)
parentPath = (
await handler.list(vdiDir, {
filter: filename => filename[0] !== '.' && filename.endsWith('.vhd'),
prependDir: true,
})
)
.sort()
.pop()
assert.notStrictEqual(
parentPath,
undefined,
`missing parent of ${id} in ${dirname(path)}, looking for ${vdi.other_config[TAG_BASE_DELTA]}`
)
parentPath = parentPath.slice(1) // remove leading slash
// TODO remove when this has been done before the export
await checkVhd(handler, parentPath)
}
// don't write it as transferSize += await async function
// since i += await asyncFun lead to race condition
// as explained : https://eslint.org/docs/latest/rules/require-atomic-updates
@@ -217,17 +235,6 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
writeBlockConcurrency: this._config.writeBlockConcurrency,
})
transferSize += transferSizeOneDisk
if (isDifferencing) {
await chainVhd(handler, parentPath, handler, path)
}
// set the correct UUID in the VHD
await Disposable.use(openVhd(handler, path), async vhd => {
vhd.footer.uuid = packUuid(vdi.uuid)
await vhd.readBlockAllocationTable() // required by writeFooter()
await vhd.writeFooter()
})
},
{
concurrency: settings.diskPerVmConcurrency,

View File

@@ -1,3 +1,4 @@
import assert from 'node:assert'
import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
import { formatDateTime } from '@xen-orchestra/xapi'
@@ -14,6 +15,7 @@ import find from 'lodash/find.js'
export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
async checkBaseVdis(baseUuidToSrcVdi, baseVm) {
assert.notStrictEqual(baseVm, undefined)
const sr = this._sr
const replicatedVm = listReplicatedVms(sr.$xapi, this._job.id, sr.uuid, this._vmUuid).find(
vm => vm.other_config[TAG_COPY_SRC] === baseVm.uuid
@@ -36,7 +38,9 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
}
}
}
updateUuidAndChain() {
// nothing to do, the chaining is not modified in this case
}
prepare({ isFull }) {
// create the task related to this export and ensure all methods are called in this context
const task = new Task({

View File

@@ -5,6 +5,10 @@ export class AbstractIncrementalWriter extends AbstractWriter {
throw new Error('Not implemented')
}
updateUuidAndChain() {
throw new Error('Not implemented')
}
cleanup() {
throw new Error('Not implemented')
}

View File

@@ -113,13 +113,13 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
)
}
_isAlreadyTransferred(timestamp) {
async _isAlreadyTransferred(timestamp) {
const vmUuid = this._vmUuid
const adapter = this._adapter
const backupDir = getVmBackupDir(vmUuid)
try {
const actualMetadata = JSON.parse(
adapter._handler.readFile(`${backupDir}/${formatFilenameDate(timestamp)}.json`)
await adapter._handler.readFile(`${backupDir}/${formatFilenameDate(timestamp)}.json`)
)
return actualMetadata
} catch (error) {}

View File

@@ -230,6 +230,7 @@ Settings are described in [`@xen-orchestra/backups/\_runners/VmsXapi.mjs``](http
- `checkBaseVdis(baseUuidToSrcVdi, baseVm)`
- `prepare({ isFull })`
- `transfer({ timestamp, deltaExport, sizeContainers })`
- `updateUuidAndChain({ isVhdDifferencing, vdis })`
- `cleanup()`
- `healthCheck()` // is not executed if no health check sr or tag doesn't match
- **Full**

View File

@@ -20,5 +20,7 @@ export function split(path) {
return parts
}
export const relativeFromFile = (file, path) => relative(dirname(file), path)
// paths are made absolute otherwise fs.relative() would resolve them against working directory
export const relativeFromFile = (file, path) => relative(dirname(normalize(file)), normalize(path))
export const resolveFromFile = (file, path) => resolve('/', dirname(file), path).slice(1)

View File

@@ -0,0 +1,17 @@
import { describe, it } from 'test'
import { strict as assert } from 'assert'
import { relativeFromFile } from './path.js'
describe('relativeFromFile()', function () {
for (const [title, args] of Object.entries({
'file absolute and path absolute': ['/foo/bar/file.vhd', '/foo/baz/path.vhd'],
'file relative and path absolute': ['foo/bar/file.vhd', '/foo/baz/path.vhd'],
'file absolute and path relative': ['/foo/bar/file.vhd', 'foo/baz/path.vhd'],
'file relative and path relative': ['foo/bar/file.vhd', 'foo/baz/path.vhd'],
})) {
it('works with ' + title, function () {
assert.equal(relativeFromFile(...args), '../baz/path.vhd')
})
}
})

View File

@@ -17,7 +17,7 @@
"xo-lift-remote-immutability": "./liftProtection.mjs"
},
"license": "AGPL-3.0-or-later",
"version": "1.0.0",
"version": "1.0.1",
"engines": {
"node": ">=14.0.0"
},

View File

@@ -54,10 +54,10 @@ async function handleExistingFile(root, indexPath, path) {
await indexFile(fullPath, indexPath)
}
}
} catch (err) {
if (err.code !== 'EEXIST') {
} catch (error) {
if (error.code !== 'EEXIST') {
// there can be a symbolic link in the tree
warn('handleExistingFile', err)
warn('handleExistingFile', { error })
}
}
}
@@ -106,7 +106,7 @@ export async function watchRemote(remoteId, { root, immutabilityDuration, rebuil
await File.liftImmutability(settingPath)
} catch (error) {
// file may not exists, and it's not really a problem
info('lifting immutability on current settings', error)
info('lifting immutability on current settings', { error })
}
await fs.writeFile(
settingPath,

View File

@@ -50,7 +50,17 @@ const RRD_POINTS_PER_STEP: { [key in RRD_STEP]: number } = {
// Utils
// -------------------------------------------------------------------
function convertNanToNull(value: number) {
function parseNumber(value: number | string) {
// Starting from XAPI 23.31, numbers in the JSON payload are encoded as
// strings to support NaN, Infinity and -Infinity
if (typeof value === 'string') {
const asNumber = +value
if (isNaN(asNumber) && value !== 'NaN') {
throw new Error('cannot parse number: ' + value)
}
value = asNumber
}
return isNaN(value) ? null : value
}
@@ -59,7 +69,7 @@ function convertNanToNull(value: number) {
// -------------------------------------------------------------------
const computeValues = (dataRow: any, legendIndex: number, transformValue = identity) =>
map(dataRow, ({ values }) => transformValue(convertNanToNull(values[legendIndex])))
map(dataRow, ({ values }) => transformValue(parseNumber(values[legendIndex])))
const createGetProperty = (obj: object, property: string, defaultValue: unknown) =>
defaults(obj, { [property]: defaultValue })[property] as any
@@ -319,8 +329,14 @@ export default class XapiStats {
},
abortSignal,
})
// eslint-disable-next-line import/no-named-as-default-member -- https://github.com/json5/json5/issues/287
return JSON5.parse(await resp.text())
const text = await resp.text()
try {
// starting from XAPI 23.31, the response is valid JSON
return JSON.parse(text)
} catch (error) {
// eslint-disable-next-line import/no-named-as-default-member -- https://github.com/json5/json5/issues/287
return JSON5.parse(text)
}
}
// To avoid multiple requests, we keep a cache for the stats and
@@ -383,7 +399,10 @@ export default class XapiStats {
abortSignal,
})
const actualStep = json.meta.step as number
const actualStep = parseNumber(json.meta.step)
if (actualStep !== step) {
throw new FaultyGranularity(`Unable to get the true granularity: ${actualStep}`)
}
if (json.data.length > 0) {
// fetched data is organized from the newest to the oldest
@@ -407,14 +426,15 @@ export default class XapiStats {
let stepStats = xoObjectStats[actualStep]
let cacheStepStats = cacheXoObjectStats[actualStep]
if (stepStats === undefined || stepStats.endTimestamp !== json.meta.end) {
const endTimestamp = parseNumber(json.meta.end)
if (stepStats === undefined || stepStats.endTimestamp !== endTimestamp) {
stepStats = xoObjectStats[actualStep] = {
endTimestamp: json.meta.end,
endTimestamp,
interval: actualStep,
canBeExpired: false,
}
cacheStepStats = cacheXoObjectStats[actualStep] = {
endTimestamp: json.meta.end,
endTimestamp,
interval: actualStep,
canBeExpired: true,
}
@@ -438,10 +458,6 @@ export default class XapiStats {
})
})
}
if (actualStep !== step) {
throw new FaultyGranularity(`Unable to get the true granularity: ${actualStep}`)
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return

View File

@@ -1,13 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "../web-core/lib/**/*", "../web-core/lib/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"noEmit": true,
"baseUrl": ".",
"rootDir": "..",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@core/*": ["../web-core/lib/*"]
}
}
}

View File

@@ -23,6 +23,7 @@ export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@core': fileURLToPath(new URL('../web-core/lib', import.meta.url)),
},
},

View File

@@ -27,6 +27,16 @@ log.error('could not join server', {
})
```
A logging method has the following signature:
```ts
interface LoggingMethod {
(error): void
(message: string, data?: { error?: Error; [property: string]: any }): void
}
```
### Consumer
Then, at application level, configure the logs are handled:

View File

@@ -45,6 +45,16 @@ log.error('could not join server', {
})
```
A logging method has the following signature:
```ts
interface LoggingMethod {
(error): void
(message: string, data?: { error?: Error; [property: string]: any }): void
}
```
### Consumer
Then, at application level, configure the logs are handled:

View File

@@ -10,7 +10,8 @@
}
},
"devDependencies": {
"vue": "^3.4.13"
"vue": "^3.4.13",
"@vue/tsconfig": "^0.5.1"
},
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/web-core",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
@@ -25,6 +26,6 @@
},
"license": "AGPL-3.0-or-later",
"engines": {
"node": ">=8.10"
"node": ">=18"
}
}

View File

@@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "lib/**/*", "lib/**/*.vue"],
"exclude": ["lib/**/__tests__/*"],
"compilerOptions": {
"noEmit": true,
"baseUrl": ".",
"paths": {
"@core/*": ["./lib/*"]
}
}
}

View File

@@ -1,13 +1,22 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "typed-router.d.ts", "src/**/*", "src/**/*.vue"],
"include": [
"env.d.ts",
"typed-router.d.ts",
"src/**/*",
"src/**/*.vue",
"../web-core/lib/**/*",
"../web-core/lib/**/*.vue"
],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"noEmit": true,
"baseUrl": ".",
"rootDir": "..",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@core/*": ["../web-core/lib/*"]
}
}
}

View File

@@ -11,6 +11,7 @@ export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@core': fileURLToPath(new URL('../web-core/lib', import.meta.url)),
},
},
})

View File

@@ -21,12 +21,23 @@ export default class Vif {
MAC = '',
} = {}
) {
if (device === undefined) {
const allowedDevices = await this.call('VM.get_allowed_VIF_devices', VM)
if (allowedDevices.length === 0) {
const error = new Error('could not find an allowed VIF device')
error.poolUuid = this.pool.uuid
error.vmRef = VM
throw error
}
device = allowedDevices[0]
}
const [powerState, ...rest] = await Promise.all([
this.getField('VM', VM, 'power_state'),
device ?? (await this.call('VM.get_allowed_VIF_devices', VM))[0],
MTU ?? (await this.getField('network', network, 'MTU')),
MTU ?? this.getField('network', network, 'MTU'),
])
;[device, MTU] = rest
;[MTU] = rest
const vifRef = await this.call('VIF.create', {
currently_attached: powerState === 'Suspended' ? currently_attached : undefined,

View File

@@ -0,0 +1,3 @@
const formatCounter = counter => String(counter).padStart(8, '0')
export const formatBlockPath = (basePath, counter) => `${basePath}/${formatCounter(counter)}`

View File

@@ -1,30 +1,50 @@
import { formatBlockPath } from './_formatBlockPath.mjs'
import { fromCallback } from 'promise-toolbox'
import { readChunkStrict } from '@vates/read-chunk'
import { xxhash64 } from 'hash-wasm'
export const XVA_DISK_CHUNK_LENGTH = 1024 * 1024
async function addEntry(pack, name, buffer) {
await fromCallback.call(pack, pack.entry, { name }, buffer)
}
async function writeBlock(pack, data, name) {
await fromCallback.call(pack, pack.entry, { name }, data)
if (data.length < XVA_DISK_CHUNK_LENGTH) {
data = Buffer.concat([data, Buffer.alloc(XVA_DISK_CHUNK_LENGTH - data.length, 0)])
}
await addEntry(pack, name, data)
// weirdly, ocaml and xxhash return the bytes in reverse order to each other
const hash = (await xxhash64(data)).toString('hex').toUpperCase()
await fromCallback.call(pack, pack.entry, { name: `${name}.xxhash` }, Buffer.from(hash, 'utf8'))
await addEntry(pack, `${name}.xxhash`, Buffer.from(hash, 'utf8'))
}
export default async function addDisk(pack, vhd, basePath) {
let counter = 0
let written
const chunk_length = 1024 * 1024
const empty = Buffer.alloc(chunk_length, 0)
let lastBlockWrittenAt = Date.now()
const MAX_INTERVAL_BETWEEN_BLOCKS = 60 * 1000
const empty = Buffer.alloc(XVA_DISK_CHUNK_LENGTH, 0)
const stream = await vhd.rawContent()
let lastBlockLength
const diskSize = vhd.footer.currentSize
let remaining = diskSize
while (remaining > 0) {
const data = await readChunkStrict(stream, Math.min(chunk_length, remaining))
lastBlockLength = data.length
lastBlockLength = Math.min(XVA_DISK_CHUNK_LENGTH, remaining)
const data = await readChunkStrict(stream, lastBlockLength)
remaining -= lastBlockLength
if (counter === 0 || !data.equals(empty)) {
if (
// write first block
counter === 0 ||
// write all non empty blocks
!data.equals(empty) ||
// write one block from time to time to ensure there is no timeout
// occurring while passing empty blocks
Date.now() - lastBlockWrittenAt > MAX_INTERVAL_BETWEEN_BLOCKS
) {
written = true
await writeBlock(pack, data, `${basePath}/${('' + counter).padStart(8, '0')}`)
await writeBlock(pack, data, formatBlockPath(basePath, counter))
lastBlockWrittenAt = Date.now()
} else {
written = false
}
@@ -32,6 +52,6 @@ export default async function addDisk(pack, vhd, basePath) {
}
if (!written) {
// last block must be present
writeBlock(pack, empty.slice(0, lastBlockLength), `${basePath}/${counter}`)
await writeBlock(pack, empty, formatBlockPath(basePath, counter - 1))
}
}

View File

@@ -9,6 +9,7 @@ import { DEFAULT_VDI } from './templates/vdi.mjs'
import { DEFAULT_VIF } from './templates/vif.mjs'
import { DEFAULT_VM } from './templates/vm.mjs'
import toOvaXml from './_toOvaXml.mjs'
import { XVA_DISK_CHUNK_LENGTH } from './_writeDisk.mjs'
export default async function writeOvaXml(
pack,
@@ -79,11 +80,12 @@ export default async function writeOvaXml(
for (let index = 0; index < vhds.length; index++) {
const userdevice = index + 1
const vhd = vhds[index]
const alignedSize = Math.ceil(vdis[index].virtual_size / XVA_DISK_CHUNK_LENGTH) * XVA_DISK_CHUNK_LENGTH
const vdi = defaultsDeep(
{
id: nextRef(),
// overwrite SR from an opaqref to a ref:
snapshot: { ...vdis[index], SR: srObj.id },
// overwrite SR from an opaque ref to a ref:
snapshot: { ...vdis[index], SR: srObj.id, virtual_size: alignedSize },
},
{
snapshot: {

View File

@@ -25,8 +25,7 @@ export async function importVm(vm, xapi, sr, network) {
const str = await promise
const matches = /OpaqueRef:[0-9a-z-]+/.exec(str)
if (!matches) {
const error = new Error('no opaque ref found')
error.haystack = str
const error = new Error(`no opaque ref found in ${str}`)
throw error
}
return matches[0]

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/xva",
"version": "1.0.0",
"version": "1.0.2",
"main": "index.js",
"author": "",
"license": "ISC",

View File

@@ -1,9 +1,46 @@
# ChangeLog
## **5.91.0** (2024-01-31)
## **5.91.2** (2024-02-09)
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
### Enhancements
- [REST API] Add `/groups` collection [Forum#70500](https://xcp-ng.org/forum/post/70500)
- [REST API] Add `/groups/:id/users` and `/users/:id/groups` collection [Forum#70500](https://xcp-ng.org/forum/post/70500)
- [REST API] Expose messages associated to XAPI objects at `/:collection/:object/messages`
### Bug fixes
- [Import/VMWare] Fix `(Failure \"Expected string, got 'I(0)'\")` (PR [#7361](https://github.com/vatesfr/xen-orchestra/issues/7361))
- [Plugin/load-balancer] Fixing `TypeError: Cannot read properties of undefined (reading 'high')` happening when trying to optimize a host with performance plan [#7359](https://github.com/vatesfr/xen-orchestra/issues/7359) (PR [#7362](https://github.com/vatesfr/xen-orchestra/pull/7362))
- Changing the number of displayed items per page should send back to the first page [#7350](https://github.com/vatesfr/xen-orchestra/issues/7350)
- [Plugin/load-balancer] Correctly create a _simple_ instead of a _density_ plan when it is selected (PR [#7358](https://github.com/vatesfr/xen-orchestra/pull/7358))
### Released packages
- xo-server 5.136.0
- xo-server-load-balancer 0.8.1
- xo-web 5.136.1
## **5.91.1** (2024-02-06)
### Bug fixes
- [Import/VMWare] Fix `Error: task has been destroyed before completion` with XVA import [Forum#70513](https://xcp-ng.org/forum/post/70513)
- [Import/VM] Fix `UUID_INVALID(VM, OpaqueRef:...)` error when importing from URL
- [Proxies] Fix `xapi.getOrWaitObject is not a function` is not a function during deployment
- [REST API] Fix empty object's tasks list
- [REST API] Fix incorrect `href` in `/:collection/:object/tasks`
### Released packages
- @xen-orchestra/immutable-backups 1.0.1
- @xen-orchestra/xva 1.0.2
- xo-server 5.135.1
## **5.91.0** (2024-01-31)
### Highlights
- [Import/VMWare] Speed up import and make all imports thin [#7323](https://github.com/vatesfr/xen-orchestra/issues/7323)

View File

@@ -7,17 +7,21 @@
> Users must be able to say: “Nice enhancement, I'm eager to test it”
- Disable search engine indexing via a `robots.txt`
- [Stats] Support format used by XAPI 23.31
- [REST API] Export host [SMT](https://en.wikipedia.org/wiki/Simultaneous_multithreading) status at `/hosts/:id/smt` [Forum#71374](https://xcp-ng.org/forum/post/71374)
- [Home & REST API] `$container` field of an halted VM now points to a host if a VDI is on a local storage [Forum#71769](https://xcp-ng.org/forum/post/71769)
- [Size Input] Ability to select two new units in the dropdown (`TiB`, `PiB`) (PR [#7382](https://github.com/vatesfr/xen-orchestra/pull/7382))
### Bug fixes
> Users must be able to say: “I had this issue, happy to know it's fixed”
- [Import/VM] Fix `UUID_INVALID(VM, OpaqueRef:...)` error when importing from URL
- [Proxies] Fix `xapi.getOrWaitObject is not a function` is not a function during deployment
- [REST API] Fix empty object's tasks list
- [REST API] Fix incorrect `href` in `/:collection/:object/tasks`
- [VM/Migration] Fix VDIs that were not migrated to the destination SR (PR [#7360](https://github.com/vatesfr/xen-orchestra/pull/7360))
- [Home/VM] VMs migration from the home view will no longer execute a `migrate_send` unless it is necessary [Forum#8279](https://xcp-ng.org/forum/topic/8279/getting-errors-when-migrating-4-out-5-vmguest/)(PR [#7360](https://github.com/vatesfr/xen-orchestra/pull/7360))
- [VM/migration] SR is no longer required if you select a migration network (PR [#7360](https://github.com/vatesfr/xen-orchestra/pull/7360))
- [Settings/XO Config] Sort backups from newest to oldest
- [Plugins/audit] Don't log `tag.getAllConfigured` calls
- [Remotes] Correctly clear error when the remote is tested with success
- [Import/VMWare] Fix importing last snapshot (PR [#7370](https://github.com/vatesfr/xen-orchestra/pull/7370))
### Packages to release
@@ -35,9 +39,12 @@
<!--packages-start-->
- @xen-orchestra/immutable-backups patch
- @xen-orchestra/xva patch
- xo-server patch
- xo-web patch
- @xen-orchestra/backups patch
- @xen-orchestra/fs patch
- @xen-orchestra/xapi patch
- vhd-lib patch
- xo-server minor
- xo-server-audit patch
- xo-web minor
<!--packages-end-->

View File

@@ -34,9 +34,8 @@ But it's not the only way to see this: there is multiple possibilities to "optim
- maybe you want to spread the VM load on the maximum number of server, to get the most of your hardware? (previous example)
- maybe you want to reduce power consumption and migrate your VMs to the minimum number of hosts possible? (and shutdown useless hosts)
- or maybe both, depending of your own schedule?
Those ways can be also called modes: "performance" for 1, "density" for number 2 and "mixed" for the last.
Those ways can be also called modes: "performance" for 1 and "density" for number 2.
## Configure a plan
@@ -47,7 +46,6 @@ A plan has:
- a name
- pool(s) where to apply the policy
- a mode (see paragraph below)
- a behavior (aggressive, normal, low)
### Plan modes
@@ -55,7 +53,7 @@ There are 3 modes possible:
- performance
- density
- mixed
- simple
#### Performance
@@ -65,14 +63,9 @@ VMs are placed to use all possible resources. This means balance the load to giv
This time, the objective is to use the least hosts possible, and to concentrate your VMs. In this mode, you can choose to shutdown unused (and compatible) hosts.
#### Mixed
#### Simple
This mode allows you to use both performance and density, but alternatively, depending of a schedule. E.g:
- **performance** from 6:00 AM to 7:00 PM
- **density** from 7:01 PM to 5:59 AM
In this case, you'll have the best of both when needed (energy saving during the night and performance during the day).
This mode allows you to use VM anti-affinity without using any load balancing mechanism. (see paragraph below)
### Threshold
@@ -87,6 +80,10 @@ If the CPU threshold is set to 90%, the load balancer will be only triggered if
For free memory, it will be triggered if there is **less** free RAM than the threshold.
### Exclusion
If you want to prevent load balancing from triggering migrations on a particular host or VM, it is possible to exclude it from load balancing. It can be configured via the "Excluded hosts" parameter in each plan, and in the "Ignored VM tags" parameter which is common to every plan.
### Timing
The global situation (resource usage) is examined **every minute**.

View File

@@ -93,6 +93,21 @@ Follow the instructions:
You can also download XOA from xen-orchestra.com in an XVA file. Once you've got the XVA file, you can import it with `xe vm-import filename=xoa_unified.xva` or via XenCenter.
If you want to use static IP address for your appliance:
```sh
xe vm-param-set uuid="$uuid" \
xenstore-data:vm-data/ip="$ip" \
xenstore-data:vm-data/netmask="$netmask" \
xenstore-data:vm-data/gateway="$gateway"
```
If you want to replace the default DNS server:
```sh
xe vm-param-set uuid="$uuid" xenstore-data:vm-data/dns="$dns"
```
After the VM is imported, you just need to start it with `xe vm-start vm="XOA"` or with XenCenter.
## First console connection

View File

@@ -1,4 +1,6 @@
{
"name": "xen-orchestra",
"version": "0.0.0",
"devDependencies": {
"@babel/core": "^7.0.0",
"@babel/eslint-parser": "^7.13.8",
@@ -94,7 +96,7 @@
},
"private": true,
"scripts": {
"build": "turbo run build --scope xo-server --scope xo-server-'*' --scope xo-web",
"build": "TURBO_TELEMETRY_DISABLED=1 turbo run build --scope xo-server --scope xo-server-'*' --scope xo-web",
"build:xo-lite": "turbo run build --scope @xen-orchestra/lite",
"clean": "scripts/run-script.js --parallel clean",
"dev": "scripts/run-script.js --parallel --concurrency 0 --verbose dev",

View File

@@ -46,9 +46,9 @@ const VhdSynthetic = class VhdSynthetic extends VhdAbstract {
}
get compressionType() {
const compressionType = this.vhds[0].compressionType
for (let i = 0; i < this.vhds.length; i++) {
if (compressionType !== this.vhds[i].compressionType) {
const compressionType = this.#vhds[0].compressionType
for (let i = 0; i < this.#vhds.length; i++) {
if (compressionType !== this.#vhds[i].compressionType) {
return 'MIXED'
}
}

View File

@@ -1,6 +1,6 @@
'use strict'
const { dirname, relative } = require('path')
const { relativeFromFile } = require('@xen-orchestra/fs/path')
const { openVhd } = require('./openVhd')
const { DISK_TYPES } = require('./_constants')
@@ -21,7 +21,7 @@ module.exports = async function chain(parentHandler, parentPath, childHandler, c
}
await childVhd.readBlockAllocationTable()
const parentName = relative(dirname(childPath), parentPath)
const parentName = relativeFromFile(childPath, parentPath)
header.parentUuid = parentVhd.footer.uuid
header.parentUnicodeName = parentName
await childVhd.setUniqueParentLocator(parentName)

View File

@@ -72,6 +72,7 @@ const DEFAULT_BLOCKED_LIST = {
'system.getServerTimezone': true,
'system.getServerVersion': true,
'system.getVersion': true,
'tag.getAllConfigured': true,
'test.getPermissionsForUser': true,
'user.getAll': true,
'user.getAuthenticationTokens': true,

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server-load-balancer",
"version": "0.8.0",
"version": "0.8.1",
"license": "AGPL-3.0-or-later",
"description": "Load balancer for XO-Server",
"keywords": [

View File

@@ -12,6 +12,8 @@ import { EXECUTION_DELAY, debug } from './utils'
const PERFORMANCE_MODE = 0
const DENSITY_MODE = 1
const SIMPLE_MODE = 2
const MODES = { 'Performance mode': PERFORMANCE_MODE, 'Density mode': DENSITY_MODE, 'Simple mode': SIMPLE_MODE }
// ===================================================================
@@ -35,7 +37,7 @@ export const configurationSchema = {
},
mode: {
enum: ['Performance mode', 'Density mode', 'Simple mode'],
enum: Object.keys(MODES),
title: 'Mode',
},
@@ -147,7 +149,7 @@ class LoadBalancerPlugin {
if (plans) {
for (const plan of plans) {
this._addPlan(plan.mode === 'Performance mode' ? PERFORMANCE_MODE : DENSITY_MODE, plan)
this._addPlan(MODES[plan.mode], plan)
}
}
}

View File

@@ -178,7 +178,7 @@ export default class PerformancePlan extends Plan {
const state = this._getThresholdState(exceededAverages)
if (
destinationAverages.cpu + vmAverages.cpu >= this._thresholds.cpu.low ||
destinationAverages.memoryFree - vmAverages.memory <= this._thresholds.memory.high ||
destinationAverages.memoryFree - vmAverages.memory <= this._thresholds.memoryFree.high ||
(!state.cpu &&
!state.memory &&
(exceededAverages.cpu - vmAverages.cpu < destinationAverages.cpu + vmAverages.cpu ||

View File

@@ -143,6 +143,7 @@ port = 80
requestTimeout = 0
[http.mounts]
'/robots.txt' = './robots.txt'
'/' = '../xo-web/dist/'
'/v6' = '../../@xen-orchestra/web/dist/'

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "xo-server",
"version": "5.135.0",
"version": "5.136.0",
"license": "AGPL-3.0-or-later",
"description": "Server part of Xen-Orchestra",
"keywords": [
@@ -53,7 +53,7 @@
"@xen-orchestra/template": "^0.1.0",
"@xen-orchestra/vmware-explorer": "^0.4.0",
"@xen-orchestra/xapi": "^4.2.0",
"@xen-orchestra/xva": "^1.0.0",
"@xen-orchestra/xva": "^1.0.2",
"ajv": "^8.0.3",
"app-conf": "^2.3.0",
"async-iterator-to-stream": "^1.0.1",

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -27,7 +27,7 @@ async function sendToNagios(app, jobName, vmBackupInfo) {
jobName
)
} catch (error) {
warn('sendToNagios:', error)
warn('sendToNagios:', { error })
}
}

View File

@@ -48,15 +48,18 @@ const getLogs = (db, args) => {
const deleteLogs = (db, args) =>
new Promise(resolve => {
let count = 1
let nDeleted = 0
let nRunning = 1
const cb = () => {
if (--count === 0) {
if (--nRunning === 0) {
console.log(nDeleted.toLocaleString(), 'deleted entries')
resolve()
}
}
const deleteEntry = key => {
++count
++nDeleted
++nRunning
db.del(key, cb)
}

View File

@@ -328,6 +328,34 @@ const TRANSFORMS = {
const { creation } = xoData.extract(obj) ?? {}
let $container
if (obj.resident_on !== 'OpaqueRef:NULL') {
// resident_on is set when the VM is running (or paused or suspended on a host)
$container = link(obj, 'resident_on')
} else {
// if the VM is halted, the $container is the pool
$container = link(obj, 'pool')
// unless one of its VDI is on a non shared SR
//
// linked objects may not be there when this code run, and it will only be
// refreshed when the VM XAPI record change, this value is not guaranteed
// to be up-to-date, but it practice it appears to work fine thanks to
// `VBDs` and `current_operations` changing when a VDI is
// added/removed/migrated
for (const vbd of obj.$VBDs) {
const sr = vbd?.$VDI?.$SR
if (sr !== undefined && !sr.shared) {
const pbd = sr.$PBDs[0]
const hostId = pbd && link(pbd, 'host')
if (hostId !== undefined) {
$container = hostId
break
}
}
}
}
const vm = {
// type is redefined after for controllers/, templates &
// snapshots.
@@ -422,8 +450,7 @@ const TRANSFORMS = {
xenTools,
...getVmGuestToolsProps(obj),
// TODO: handle local VMs (`VM.get_possible_hosts()`).
$container: isRunning ? link(obj, 'resident_on') : link(obj, 'pool'),
$container,
$VBDs: link(obj, 'VBDs'),
// TODO: dedupe

View File

@@ -45,7 +45,17 @@ const RRD_POINTS_PER_STEP = {
// Utils
// -------------------------------------------------------------------
function convertNanToNull(value) {
function parseNumber(value) {
// Starting from XAPI 23.31, numbers in the JSON payload are encoded as
// strings to support NaN, Infinity and -Infinity
if (typeof value === 'string') {
const asNumber = +value
if (isNaN(asNumber) && value !== 'NaN') {
throw new Error('cannot parse number: ' + value)
}
value = asNumber
}
return isNaN(value) ? null : value
}
@@ -58,7 +68,7 @@ async function getServerTimestamp(xapi, hostRef) {
// -------------------------------------------------------------------
const computeValues = (dataRow, legendIndex, transformValue = identity) =>
map(dataRow, ({ values }) => transformValue(convertNanToNull(values[legendIndex])))
map(dataRow, ({ values }) => transformValue(parseNumber(values[legendIndex])))
const combineStats = (stats, path, combineValues) => zipWith(...map(stats, path), (...values) => combineValues(values))
@@ -245,7 +255,15 @@ export default class XapiStats {
start: timestamp,
},
})
.then(response => response.text().then(JSON5.parse))
.then(response => response.text())
.then(data => {
try {
// starting from XAPI 23.31, the response is valid JSON
return JSON.parse(data)
} catch (_) {
return JSON5.parse(data)
}
})
.catch(err => {
delete this.#hostCache[hostUuid][step]
throw err
@@ -299,7 +317,7 @@ export default class XapiStats {
// To avoid crossing over the boundary, we ask for one less step
const optimumTimestamp = currentTimeStamp - maxDuration + step
const json = await this._getJson(xapi, host, optimumTimestamp, step)
const actualStep = json.meta.step
const actualStep = parseNumber(json.meta.step)
if (actualStep !== step) {
throw new FaultyGranularity(`Unable to get the true granularity: ${actualStep}`)
@@ -326,9 +344,10 @@ export default class XapiStats {
return
}
if (stepStats === undefined || stepStats.endTimestamp !== json.meta.end) {
const endTimestamp = parseNumber(json.meta.end)
if (stepStats === undefined || stepStats.endTimestamp !== endTimestamp) {
stepStats = {
endTimestamp: json.meta.end,
endTimestamp,
interval: actualStep,
stats: {},
}

View File

@@ -495,8 +495,10 @@ export default class Xapi extends XapiBase {
bypassAssert = false,
}
) {
const srRef = sr !== undefined ? hostXapi.getObject(sr).$ref : undefined
const getDefaultSrRef = once(() => {
if (sr !== undefined) {
return hostXapi.getObject(sr).$ref
}
const defaultSr = host.$pool.$default_SR
if (defaultSr === undefined) {
throw new Error(`This operation requires a default SR to be set on the pool ${host.$pool.name_label}`)
@@ -504,28 +506,6 @@ export default class Xapi extends XapiBase {
return defaultSr.$ref
})
// VDIs/SRs mapping
// For VDI:
// - If a map of VDI -> SR was explicitly passed: use it
// - Else if SR was explicitly passed: use it
// - Else if VDI SR is reachable from the destination host: use it
// - Else: use the migration main SR or the pool's default SR (error if none of them is defined)
function getMigrationSrRef(vdi) {
if (mapVdisSrs[vdi.$id] !== undefined) {
return hostXapi.getObject(mapVdisSrs[vdi.$id]).$ref
}
if (srRef !== undefined) {
return srRef
}
if (isSrConnected(vdi.$SR)) {
return vdi.$SR.$ref
}
return getDefaultSrRef()
}
const hostPbds = new Set(host.PBDs)
const connectedSrs = new Map()
const isSrConnected = sr => {
@@ -538,6 +518,10 @@ export default class Xapi extends XapiBase {
}
// VDIs/SRs mapping
// For VDI:
// - If SR was explicitly passed: use it
// - Else if VDI SR is reachable from the destination host: use it
// - Else: use the migration main SR or the pool's default SR (error if none of them is defined)
// For VDI-snapshot:
// - If VDI-snapshot is an orphan snapshot: same logic as a VDI
// - Else: don't add it to the map (VDI -> SR). It will be managed by the XAPI (snapshot will be migrated to the same SR as its parent active VDI)
@@ -550,7 +534,12 @@ export default class Xapi extends XapiBase {
if (vdi.$snapshot_of !== undefined) {
continue
}
vdis[vdi.$ref] = getMigrationSrRef(vdi)
vdis[vdi.$ref] =
mapVdisSrs[vdi.$id] !== undefined
? hostXapi.getObject(mapVdisSrs[vdi.$id]).$ref
: isSrConnected(vdi.$SR)
? vdi.$SR.$ref
: getDefaultSrRef()
}
}

View File

@@ -410,7 +410,9 @@ export default class Api {
// 2021-02-11: Work-around: ECONNREFUSED error can be triggered by
// 'host.stats' method because there is no connection to the host during a
// toolstack restart and xo-web may call it often
if (name !== 'pool.listMissingPatches' && name !== 'host.stats') {
// 2024-02-05: Work-around: in case of XO Proxy errors, `proxy.getApplianceUpdaterState` will
// flood the logs.
if (name !== 'pool.listMissingPatches' && name !== 'host.stats' && name !== 'proxy.getApplianceUpdaterState') {
this._logger.error(message, {
...data,
duration: Date.now() - startTime,

View File

@@ -249,7 +249,7 @@ export default class MigrateVm {
const disk = chainByNode[diskIndex]
const { capacity, descriptionLabel, fileName, nameLabel, path, datastore, isFull } = disk
if (isFull) {
vhd = await VhdEsxiRaw.open(esxi, datastore, path + '/' + fileName)
vhd = await VhdEsxiRaw.open(esxi, datastore, path + '/' + fileName, { thin: false })
// we don't need to read the BAT with the importVdiThroughXva process
const vdiMetadata = {
name_description: 'fromESXI' + descriptionLabel,
@@ -258,11 +258,14 @@ export default class MigrateVm {
virtual_size: capacity,
}
vdi = await importVdiThroughXva(vdiMetadata, vhd, xapi, sr)
// it can fail before the vdi is connected to the vm
$defer.onFailure.call(xapi, 'VDI_destroy', vdi.$ref)
await xapi.VBD_create({
VDI: vdi.$ref,
VM: vm.$ref,
device: `xvd${String.fromCharCode('a'.charCodeAt(0) + userdevice)}`,
userdevice: String(userdevice < 3 ? userdevice : userdevice + 1),
})
} else {
vhd = await openDeltaVmdkasVhd(esxi, datastore, path + '/' + fileName, parentVhd, {
@@ -277,7 +280,7 @@ export default class MigrateVm {
const stream = vhd.stream()
await vdi.$importContent(stream, { format: VDI_FORMAT_VHD })
}
return vhd
return { vdi, vhd }
})
)
)
@@ -312,6 +315,8 @@ export default class MigrateVm {
await xapi.VBD_create({
VDI: vdi.$ref,
VM: vm.$ref,
device: `xvd${String.fromCharCode('a'.charCodeAt(0) + userdevice)}`,
userdevice: String(userdevice < 3 ? userdevice : userdevice + 1),
})
} else {
if (parentVhd === undefined) {

View File

@@ -1,5 +1,6 @@
import asyncMapSettled from '@xen-orchestra/async-map/legacy.js'
import { basename } from 'path'
import { createLogger } from '@xen-orchestra/log'
import { format, parse } from 'xo-remote-parser'
import {
DEFAULT_ENCRYPTION_ALGORITHM,
@@ -17,17 +18,35 @@ import { Remotes } from '../models/remote.mjs'
// ===================================================================
const { warn } = createLogger('xo:mixins:remotes')
const obfuscateRemote = ({ url, ...remote }) => {
const parsedUrl = parse(url)
remote.url = format(sensitiveValues.obfuscate(parsedUrl))
return remote
}
function validatePath(url) {
const { path } = parse(url)
// these properties should be defined on the remote object itself and not as
// part of the remote URL
//
// there is a bug somewhere that keep putting them into the URL, this list
// is here to help track it
const INVALID_URL_PARAMS = ['benchmarks', 'id', 'info', 'name', 'proxy', 'enabled', 'error', 'url']
function validateUrl(url) {
const parsedUrl = parse(url)
const { path } = parsedUrl
if (path !== undefined && basename(path) === 'xo-vm-backups') {
throw invalidParameters('remote url should not end with xo-vm-backups')
}
for (const param of INVALID_URL_PARAMS) {
if (Object.hasOwn(parsedUrl, param)) {
// log with stack trace
warn(new Error('invalid remote URL param ' + param))
}
}
}
export default class {
@@ -182,6 +201,22 @@ export default class {
if (remote === undefined) {
throw noSuchObject(id, 'remote')
}
const parsedUrl = parse(remote.url)
let fixed = false
for (const param of INVALID_URL_PARAMS) {
if (Object.hasOwn(parsedUrl, param)) {
// delete the value to trace its real origin when it's added back
// with `updateRemote()`
delete parsedUrl[param]
fixed = true
}
}
if (fixed) {
remote.url = format(parsedUrl)
this._remotes.update(remote).catch(warn)
}
return remote
}
@@ -202,7 +237,7 @@ export default class {
}
async createRemote({ name, options, proxy, url }) {
validatePath(url)
validateUrl(url)
const params = {
enabled: false,
@@ -219,6 +254,10 @@ export default class {
}
updateRemote(id, { enabled, name, options, proxy, url }) {
if (url !== undefined) {
validateUrl(url)
}
const handlers = this._handlers
const handler = handlers[id]
if (handler !== undefined) {
@@ -238,7 +277,7 @@ export default class {
@synchronized()
async _updateRemote(id, { url, ...props }) {
if (url !== undefined) {
validatePath(url)
validateUrl(url)
}
const remote = await this._getRemote(id)

View File

@@ -100,6 +100,17 @@ async function sendObjects(iterable, req, res, path = req.path) {
return pipeline(makeObjectsStream(iterable, makeResult, json, res), res)
}
function handleArray(array, filter, limit) {
if (filter !== undefined) {
array = array.filter(filter)
}
if (limit < array.length) {
array.length = limit
}
return array
}
const handleOptionalUserFilter = filter => filter && CM.parse(filter).createPredicate()
const subRouter = (app, path) => {
@@ -160,77 +171,7 @@ export default class RestApi {
)
})
const types = [
'host',
'network',
'pool',
'SR',
'VBD',
'VDI-snapshot',
'VDI',
'VIF',
'VM-snapshot',
'VM-template',
'VM',
]
const collections = Object.fromEntries(
types.map(type => {
const id = type.toLocaleLowerCase() + 's'
return [id, { id, isCorrectType: _ => _.type === type, type }]
})
)
collections.backup = { id: 'backup' }
collections.restore = { id: 'restore' }
collections.tasks = { id: 'tasks' }
collections.users = { id: 'users' }
collections.hosts.routes = {
__proto__: null,
async 'audit.txt'(req, res) {
const host = req.xapiObject
res.setHeader('content-type', 'text/plain')
await pipeline(await host.$xapi.getResource('/audit_log', { host }), compressMaybe(req, res))
},
async 'logs.tar'(req, res) {
const host = req.xapiObject
res.setHeader('content-type', 'application/x-tar')
await pipeline(await host.$xapi.getResource('/host_logs_download', { host }), compressMaybe(req, res))
},
async missing_patches(req, res) {
await app.checkFeatureAuthorization('LIST_MISSING_PATCHES')
const host = req.xapiObject
res.json(await host.$xapi.listMissingPatches(host))
},
}
collections.pools.routes = {
__proto__: null,
async missing_patches(req, res) {
await app.checkFeatureAuthorization('LIST_MISSING_PATCHES')
const xapi = req.xapiObject.$xapi
const missingPatches = new Map()
await asyncEach(Object.values(xapi.objects.indexes.type.host ?? {}), async host => {
try {
for (const patch of await xapi.listMissingPatches(host)) {
const { uuid: key = `${patch.name}-${patch.version}-${patch.release}` } = patch
missingPatches.set(key, patch)
}
} catch (error) {
console.warn(host.uuid, error)
}
})
res.json(Array.from(missingPatches.values()))
},
}
const collections = { __proto__: null }
const withParams = (fn, paramsSchema) => {
fn.params = paramsSchema
@@ -238,68 +179,235 @@ export default class RestApi {
return fn
}
collections.pools.actions = {
__proto__: null,
{
const types = [
'host',
'message',
'network',
'pool',
'SR',
'VBD',
'VDI-snapshot',
'VDI',
'VIF',
'VM-snapshot',
'VM-template',
'VM',
]
function getObject(id, req) {
const { type } = this
const object = app.getObject(id, type)
create_vm: withParams(
defer(async ($defer, { xapiObject: { $xapi } }, { affinity, boot, install, template, ...params }, req) => {
params.affinityHost = affinity
params.installRepository = install?.repository
// add also the XAPI version of the object
req.xapiObject = app.getXapiObject(object)
const vm = await $xapi.createVm(template, params, undefined, req.user.id)
$defer.onFailure.call($xapi, 'VM_destroy', vm.$ref)
return object
}
function getObjects(filter, limit) {
return app.getObjects({
filter: every(this.isCorrectType, filter),
limit,
})
}
async function messages(req, res) {
const {
object: { id },
query,
} = req
await sendObjects(
app.getObjects({
filter: every(_ => _.type === 'message' && _.$object === id, handleOptionalUserFilter(query.filter)),
limit: ifDef(query.limit, Number),
}),
req,
res,
'/messages'
)
}
for (const type of types) {
const id = type.toLocaleLowerCase() + 's'
if (boot) {
await $xapi.callAsync('VM.start', vm.$ref, false, false)
}
collections[id] = { getObject, getObjects, routes: { messages }, isCorrectType: _ => _.type === type, type }
}
return vm.uuid
}),
{
affinity: { type: 'string', optional: true },
auto_poweron: { type: 'boolean', optional: true },
boot: { type: 'boolean', default: false },
clone: { type: 'boolean', default: true },
install: {
type: 'object',
optional: true,
properties: {
method: { enum: ['cdrom', 'network'] },
repository: { type: 'string' },
collections.hosts.routes = {
...collections.hosts.routes,
async 'audit.txt'(req, res) {
const host = req.xapiObject
res.setHeader('content-type', 'text/plain')
await pipeline(await host.$xapi.getResource('/audit_log', { host }), compressMaybe(req, res))
},
async 'logs.tar'(req, res) {
const host = req.xapiObject
res.setHeader('content-type', 'application/x-tar')
await pipeline(await host.$xapi.getResource('/host_logs_download', { host }), compressMaybe(req, res))
},
async missing_patches(req, res) {
await app.checkFeatureAuthorization('LIST_MISSING_PATCHES')
const host = req.xapiObject
res.json(await host.$xapi.listMissingPatches(host))
},
async smt({ xapiObject }, res) {
res.json({ enabled: await xapiObject.$xapi.isHyperThreadingEnabled(xapiObject.$id) })
},
}
collections.pools.routes = {
...collections.pools.routes,
async missing_patches(req, res) {
await app.checkFeatureAuthorization('LIST_MISSING_PATCHES')
const xapi = req.xapiObject.$xapi
const missingPatches = new Map()
await asyncEach(Object.values(xapi.objects.indexes.type.host ?? {}), async host => {
try {
for (const patch of await xapi.listMissingPatches(host)) {
const { uuid: key = `${patch.name}-${patch.version}-${patch.release}` } = patch
missingPatches.set(key, patch)
}
} catch (error) {
console.warn(host.uuid, error)
}
})
res.json(Array.from(missingPatches.values()))
},
}
collections.pools.actions = {
create_vm: withParams(
defer(async ($defer, { xapiObject: { $xapi } }, { affinity, boot, install, template, ...params }, req) => {
params.affinityHost = affinity
params.installRepository = install?.repository
const vm = await $xapi.createVm(template, params, undefined, req.user.id)
$defer.onFailure.call($xapi, 'VM_destroy', vm.$ref)
if (boot) {
await $xapi.callAsync('VM.start', vm.$ref, false, false)
}
return vm.uuid
}),
{
affinity: { type: 'string', optional: true },
auto_poweron: { type: 'boolean', optional: true },
boot: { type: 'boolean', default: false },
clone: { type: 'boolean', default: true },
install: {
type: 'object',
optional: true,
properties: {
method: { enum: ['cdrom', 'network'] },
repository: { type: 'string' },
},
},
memory: { type: 'integer', optional: true },
name_description: { type: 'string', minLength: 0, optional: true },
name_label: { type: 'string' },
template: { type: 'string' },
}
),
emergency_shutdown: async ({ xapiObject }) => {
await app.checkFeatureAuthorization('POOL_EMERGENCY_SHUTDOWN')
await xapiObject.$xapi.pool_emergencyShutdown()
},
rolling_update: async ({ object }) => {
await app.checkFeatureAuthorization('ROLLING_POOL_UPDATE')
await app.rollingPoolUpdate(object)
},
}
collections.vms.actions = {
clean_reboot: ({ xapiObject: vm }) => vm.$callAsync('clean_reboot').then(noop),
clean_shutdown: ({ xapiObject: vm }) => vm.$callAsync('clean_shutdown').then(noop),
hard_reboot: ({ xapiObject: vm }) => vm.$callAsync('hard_reboot').then(noop),
hard_shutdown: ({ xapiObject: vm }) => vm.$callAsync('hard_shutdown').then(noop),
snapshot: withParams(
async ({ xapiObject: vm }, { name_label }) => {
const ref = await vm.$snapshot({ name_label })
return vm.$xapi.getField('VM', ref, 'uuid')
},
memory: { type: 'integer', optional: true },
name_description: { type: 'string', minLength: 0, optional: true },
name_label: { type: 'string' },
template: { type: 'string' },
}
),
emergency_shutdown: async ({ xapiObject }) => {
await app.checkFeatureAuthorization('POOL_EMERGENCY_SHUTDOWN')
{ name_label: { type: 'string', optional: true } }
),
start: ({ xapiObject: vm }) => vm.$callAsync('start', false, false).then(noop),
}
}
await xapiObject.$xapi.pool_emergencyShutdown()
collections.backup = {}
collections.groups = {
getObject(id) {
return app.getGroup(id)
},
rolling_update: async ({ xoObject }) => {
await app.checkFeatureAuthorization('ROLLING_POOL_UPDATE')
await app.rollingPoolUpdate(xoObject)
async getObjects(filter, limit) {
return handleArray(await app.getAllGroups(), filter, limit)
},
routes: {
async users(req, res) {
const { filter, limit } = req.query
await sendObjects(
handleArray(
await Promise.all(req.object.users.map(id => app.getUser(id).then(getUserPublicProperties))),
handleOptionalUserFilter(filter),
ifDef(limit, Number)
),
req,
res,
'/users'
)
},
},
}
collections.vms.actions = {
__proto__: null,
clean_reboot: ({ xapiObject: vm }) => vm.$callAsync('clean_reboot').then(noop),
clean_shutdown: ({ xapiObject: vm }) => vm.$callAsync('clean_shutdown').then(noop),
hard_reboot: ({ xapiObject: vm }) => vm.$callAsync('hard_reboot').then(noop),
hard_shutdown: ({ xapiObject: vm }) => vm.$callAsync('hard_shutdown').then(noop),
snapshot: withParams(
async ({ xapiObject: vm }, { name_label }) => {
const ref = await vm.$snapshot({ name_label })
return vm.$xapi.getField('VM', ref, 'uuid')
collections.restore = {}
collections.tasks = {}
collections.users = {
getObject(id) {
return app.getUser(id).then(getUserPublicProperties)
},
async getObjects(filter, limit) {
return handleArray(await app.getAllUsers(), filter, limit)
},
routes: {
async groups(req, res) {
const { filter, limit } = req.query
await sendObjects(
handleArray(
await Promise.all(req.object.groups.map(id => app.getGroup(id))),
handleOptionalUserFilter(filter),
ifDef(limit, Number)
),
req,
res,
'/groups'
)
},
{ name_label: { type: 'string', optional: true } }
),
start: ({ xapiObject: vm }) => vm.$callAsync('start', false, false).then(noop),
},
}
// normalize collections
for (const id of Object.keys(collections)) {
const collection = collections[id]
// inject id into the collection
collection.id = id
// set null as prototypes to speed-up look-ups
Object.setPrototypeOf(collection, null)
const { actions, routes } = collection
if (actions !== undefined) {
Object.setPrototypeOf(actions, null)
}
if (routes !== undefined) {
Object.setPrototypeOf(routes, null)
}
}
api.param('collection', (req, res, next) => {
@@ -312,14 +420,14 @@ export default class RestApi {
next()
}
})
api.param('object', (req, res, next) => {
api.param('object', async (req, res, next) => {
const id = req.params.object
const { type } = req.collection
try {
req.xapiObject = app.getXapiObject((req.xoObject = app.getObject(id, type)))
next()
// eslint-disable-next-line require-atomic-updates
req.object = await req.collection.getObject(id, req)
return next()
} catch (error) {
if (noSuchObject.is(error, { id, type })) {
if (noSuchObject.is(error, { id })) {
next('route')
} else {
next(error)
@@ -478,39 +586,12 @@ export default class RestApi {
}, true)
)
api
.get(
'/users',
wrap(async (req, res) => {
let users = await app.getAllUsers()
const { filter, limit } = req.query
if (filter !== undefined) {
users = users.filter(CM.parse(filter).createPredicate())
}
if (limit < users.length) {
users.length = limit
}
sendObjects(users.map(getUserPublicProperties), req, res)
})
)
.get(
'/users/:id',
wrap(async (req, res) => {
res.json(getUserPublicProperties(await app.getUser(req.params.id)))
})
)
api.get(
'/:collection',
wrap(async (req, res) => {
const { query } = req
await sendObjects(
await app.getObjects({
filter: every(req.collection.isCorrectType, handleOptionalUserFilter(query.filter)),
limit: ifDef(query.limit, Number),
}),
await req.collection.getObjects(handleOptionalUserFilter(query.filter), ifDef(query.limit, Number)),
req,
res
)
@@ -563,7 +644,7 @@ export default class RestApi {
)
api.get('/:collection/:object', (req, res) => {
let result = req.xoObject
let result = req.object
// add locations of sub-routes for discoverability
const { routes } = req.collection
@@ -618,7 +699,7 @@ export default class RestApi {
'/:collection/:object/tasks',
wrap(async (req, res) => {
const { query } = req
const objectId = req.xoObject.id
const objectId = req.object.id
const tasks = app.tasks.list({
filter: every(
_ => _.status === 'pending' && _.properties.objectId === objectId,
@@ -658,9 +739,9 @@ export default class RestApi {
}
}
const { xapiObject, xoObject } = req
const task = app.tasks.create({ name: `REST: ${action} ${req.collection.type}`, objectId: xoObject.id })
const pResult = task.run(() => fn({ xapiObject, xoObject }, params, req))
const { object, xapiObject } = req
const task = app.tasks.create({ name: `REST: ${action} ${req.collection.type}`, objectId: object.id })
const pResult = task.run(() => fn({ object, xapiObject }, params, req))
if (Object.hasOwn(req.query, 'sync')) {
pResult.then(result => res.json(result), next)
} else {

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "xo-web",
"version": "5.136.0",
"version": "5.136.1",
"license": "AGPL-3.0-or-later",
"description": "Web interface client for Xen-Orchestra",
"keywords": [

View File

@@ -138,7 +138,7 @@ export class Range extends Component {
export Toggle from './toggle'
const UNITS = ['kiB', 'MiB', 'GiB']
const UNITS = ['kiB', 'MiB', 'GiB', 'TiB', 'PiB']
const DEFAULT_UNIT = 'GiB'
export class SizeInput extends BaseComponent {

View File

@@ -2046,7 +2046,6 @@ const messages = {
vmsWithDuplicatedMacAddressesMessage:
'{nVms, number} VM{nVms, plural, one {} other {s}} contain{nVms, plural, one {s} other {}} duplicate MAC addresses or {nVms, plural, one {has} other {have}} the same MAC addresses as other running VMs. Do you want to continue?',
ignoreVdi: 'Ignore this VDI',
selectDestinationSr: 'Select a destination SR',
// ----- Servers -----
enableServerErrorTitle: 'Enable server',

View File

@@ -75,7 +75,7 @@ export const reportOnSupportPanel = async ({ files = [], formatMessage = identit
ADDITIONAL_FILES.map(({ fetch, name }) =>
timeout.call(fetch(), ADDITIONAL_FILES_FETCH_TIMEOUT).then(
file => formData.append('attachments', createBlobFromString(file), name),
error => logger.warn(`cannot get ${name}`, error)
error => logger.warn(`cannot get ${name}`, { error })
)
)
)

View File

@@ -1099,7 +1099,9 @@ export const SelectXoCloudConfig = makeSubscriptionSelect(
subscriber =>
subscribeCloudXoConfigBackups(configs => {
const xoObjects = groupBy(
map(configs, config => ({ ...config, type: 'xoConfig' })),
map(configs, config => ({ ...config, type: 'xoConfig' }))
// from newest to oldest
.sort((a, b) => b.createdAt - a.createdAt),
'xoaId'
)
subscriber({

View File

@@ -752,6 +752,11 @@ class SortedTable extends Component {
const { location, stateUrlParam } = this.props
this.setState({ itemsPerPage })
cookies.set(`${location.pathname}-${stateUrlParam}`, itemsPerPage)
// changing the number of items per page should send back to the first page
//
// see https://github.com/vatesfr/xen-orchestra/issues/7350
this._setPage(1)
}
render() {

View File

@@ -59,7 +59,6 @@ export default class ChooseSrForEachVdisModal extends Component {
ignorableVdis = false,
mainSrPredicate = isSrWritable,
placeholder,
required,
srPredicate = mainSrPredicate,
value: { mainSr, mapVdisSrs },
vdis,
@@ -67,21 +66,15 @@ export default class ChooseSrForEachVdisModal extends Component {
return (
<div>
<SingleLineRow>
<Col size={6}>{_('selectDestinationSr')}</Col>
<Col size={6}>
<SelectSr
onChange={this._onChangeMainSr}
placeholder={placeholder !== undefined ? placeholder : _('chooseSrForEachVdisModalMainSr')}
predicate={mainSrPredicate}
required
value={mainSr}
/>
</Col>
</SingleLineRow>
{!required && <i>{_('optionalEntry')}</i>}
<SelectSr
onChange={this._onChangeMainSr}
placeholder={placeholder !== undefined ? placeholder : _('chooseSrForEachVdisModalMainSr')}
predicate={mainSrPredicate}
required
value={mainSr}
/>
<br />
{!isEmpty(vdis) && (
{!isEmpty(vdis) && mainSr != null && (
<Collapsible buttonText={_('chooseSrForEachVdisModalSelectSr')} collapsible size='small'>
<br />
<Container>

View File

@@ -1688,16 +1688,17 @@ export const migrateVm = async (vm, host) => {
return
}
const { sr, srRequired, targetHost } = params
const { migrationNetwork, sr, targetHost } = params
if (!targetHost) {
return error(_('migrateVmNoTargetHost'), _('migrateVmNoTargetHostMessage'))
}
if (srRequired && sr === undefined) {
// Workaround to prevent VM's VDIs from unexpectedly migrating to the default SR
// if migration network is defined, the SR is required.
if (migrationNetwork !== undefined && sr === undefined) {
return error(_('migrateVmNoSr'), _('migrateVmNoSrMessage'))
}
delete params.srRequired
try {
await _call('vm.migrate', { vm: vm.id, ...params })
@@ -1732,11 +1733,6 @@ export const migrateVms = vms =>
return error(_('migrateVmNoTargetHost'), _('migrateVmNoTargetHostMessage'))
}
if (params.srRequired && params.sr === undefined) {
return error(_('migrateVmNoTargetHost'), _('migrateVmNoTargetHostMessage'))
}
delete params.srRequired
const { mapVmsMapVdisSrs, mapVmsMapVifsNetworks, migrationNetwork, sr, targetHost, vms } = params
Promise.all(
map(vms, ({ id }) =>

View File

@@ -122,7 +122,6 @@ export default class MigrateVmModalBody extends BaseComponent {
migrationNetwork: this.state.migrationNetworkId,
sr: resolveId(this.state.targetSrs.mainSr),
targetHost: this.state.host && this.state.host.id,
srRequired: !this.state.doNotMigrateVdis,
}
}
@@ -223,14 +222,14 @@ export default class MigrateVmModalBody extends BaseComponent {
</Col>
</SingleLineRow>
</div>
{host && (
{host && (!doNotMigrateVdis || migrationNetworkId != null) && (
<div className={styles.groupBlock}>
<SingleLineRow>
<Col size={12}>
<ChooseSrForEachVdisModal
mainSrPredicate={this._getSrPredicate()}
onChange={this.linkState('targetSrs')}
required={!doNotMigrateVdis}
required
value={targetSrs}
vdis={vdis}
/>

View File

@@ -101,6 +101,10 @@ export default class MigrateVmsModalBody extends BaseComponent {
)
}
componentDidMount() {
this._selectHost(this.props.host)
}
get value() {
const { host } = this.state
const vms = filter(this.props.vms, vm => vm.$container !== host.id)
@@ -108,15 +112,7 @@ export default class MigrateVmsModalBody extends BaseComponent {
return { vms }
}
const { networks, pifs, vbdsByVm, vifsByVm } = this.props
const {
doNotMigrateVdi,
doNotMigrateVmVdis,
migrationNetworkId,
noVdisMigration,
networkId,
smartVifMapping,
srId,
} = this.state
const { doNotMigrateVdi, doNotMigrateVmVdis, migrationNetworkId, networkId, smartVifMapping, srId } = this.state
// Map VM --> ( Map VDI --> SR )
// 2021-02-16: Fill the map (VDI -> SR) with *all* the VDIs to avoid unexpectedly migrating them to the wrong SRs:
@@ -128,14 +124,10 @@ export default class MigrateVmsModalBody extends BaseComponent {
forEach(vbds, vbd => {
const vdi = vbd.VDI
if (!vbd.is_cd_drive && vdi) {
if (!doNotMigrateVmVdis[vm] && !doNotMigrateVdi[vdi]) {
mapVdisSrs[vdi] = srId
}
mapVdisSrs[vdi] = doNotMigrateVmVdis[vm] || doNotMigrateVdi[vdi] ? this._getObject(vdi).$SR : srId
}
})
if (!isEmpty(mapVdisSrs)) {
mapVmsMapVdisSrs[vm] = mapVdisSrs
}
mapVmsMapVdisSrs[vm] = mapVdisSrs
})
const defaultNetwork =
@@ -168,7 +160,6 @@ export default class MigrateVmsModalBody extends BaseComponent {
mapVmsMapVifsNetworks,
migrationNetwork: migrationNetworkId,
sr: srId,
srRequired: !noVdisMigration,
targetHost: host.id,
vms,
}
@@ -221,7 +212,7 @@ export default class MigrateVmsModalBody extends BaseComponent {
networkId: defaultMigrationNetworkId,
noVdisMigration,
smartVifMapping: true,
srId: !noVdisMigration && defaultSrConnectedToHost ? defaultSrId : undefined,
srId: defaultSrConnectedToHost ? defaultSrId : undefined,
})
}
@@ -263,11 +254,27 @@ export default class MigrateVmsModalBody extends BaseComponent {
</Col>
</SingleLineRow>
</div>
{host !== undefined && [
{host !== undefined && (
<div style={LINE_STYLE}>
<SingleLineRow>
<Col size={6}>{_('migrateVmSelectMigrationNetwork')}</Col>
<Col size={6}>
<SelectNetwork
onChange={this._selectMigrationNetwork}
predicate={this._getMigrationNetworkPredicate()}
required={!intraPool}
value={migrationNetworkId}
/>
</Col>
</SingleLineRow>
{intraPool && <i>{_('optionalEntry')}</i>}
</div>
)}
{host && (!noVdisMigration || migrationNetworkId != null) && (
<div key='sr' style={LINE_STYLE}>
<SingleLineRow>
<Col size={6}>
{_('selectDestinationSr')}{' '}
{!intraPool ? _('migrateVmsSelectSr') : _('migrateVmsSelectSrIntraPool')}{' '}
{(defaultSrId === undefined || !defaultSrConnectedToHost) && (
<Tooltip
content={
@@ -285,31 +292,11 @@ export default class MigrateVmsModalBody extends BaseComponent {
)}
</Col>
<Col size={6}>
<SelectSr
onChange={this._selectSr}
predicate={this._getSrPredicate()}
required={!noVdisMigration}
value={srId}
/>
<SelectSr onChange={this._selectSr} predicate={this._getSrPredicate()} required value={srId} />
</Col>
</SingleLineRow>
{noVdisMigration && <i>{_('optionalEntry')}</i>}
</div>,
<div style={LINE_STYLE} key='network'>
<SingleLineRow>
<Col size={6}>{_('migrateVmSelectMigrationNetwork')}</Col>
<Col size={6}>
<SelectNetwork
onChange={this._selectMigrationNetwork}
predicate={this._getMigrationNetworkPredicate()}
required={!intraPool}
value={migrationNetworkId}
/>
</Col>
</SingleLineRow>
{intraPool && <i>{_('optionalEntry')}</i>}
</div>,
]}
</div>
)}
{host && !intraPool && (
<div key='network' style={LINE_STYLE}>
<SingleLineRow>

View File

@@ -555,6 +555,11 @@ export default class Home extends Component {
_setNItemsPerPage(nItems) {
this.setState({ homeItemsPerPage: nItems })
cookies.set('homeItemsPerPage', nItems)
// changing the number of items per page should send back to the first page
//
// see https://github.com/vatesfr/xen-orchestra/issues/7350
this._onPageSelection(1)
}
_getPage() {

View File

@@ -5,10 +5,9 @@ import decorate from 'apply-decorators'
import Icon from 'icon'
import React from 'react'
import { confirm } from 'modal'
import { getApiApplianceInfo, subscribeCloudXoConfig, subscribeCloudXoConfigBackups } from 'xo'
import { groupBy, sortBy } from 'lodash'
import { injectState, provideState } from 'reaclette'
import { SelectXoCloudConfig } from 'select-objects'
import { subscribeCloudXoConfig, subscribeCloudXoConfigBackups } from 'xo'
import BackupXoConfigModal from './backup-xo-config-modal'
import RestoreXoConfigModal from './restore-xo-config-modal'
@@ -88,15 +87,7 @@ const CloudConfig = decorate([
},
},
computed: {
applianceId: async () => {
const { id } = await getApiApplianceInfo()
return id
},
groupedConfigs: ({ applianceId, sortedConfigs }) =>
sortBy(groupBy(sortedConfigs, 'xoaId'), config => (config[0].xoaId === applianceId ? -1 : 1)),
isConfigDefined: ({ config }) => config != null,
sortedConfigs: (_, { cloudXoConfigBackups }) =>
cloudXoConfigBackups?.sort((config, nextConfig) => config.createdAt - nextConfig.createdAt),
},
}),
injectState,

View File

@@ -33,7 +33,7 @@ const formatError = error => (typeof error === 'string' ? error : JSON.stringify
const _changeUrlElement = (value, { remote, element }) =>
editRemote(remote, {
url: format({ ...remote, [element]: value === null ? undefined : value }),
url: format({ ...parse(remote.url), [element]: value === null ? undefined : value }),
})
const _showError = remote => alert(_('remoteConnectionFailed'), <pre>{formatError(remote.error)}</pre>)
const _editRemoteName = (name, { remote }) => editRemote(remote, { name })

2448
yarn.lock

File diff suppressed because it is too large Load Diff