Compare commits

..

12 Commits

Author SHA1 Message Date
b-Nollet
838576c8be adding default message for unexisting VMs in backups 2024-01-29 07:59:23 +01:00
b-Nollet
e8bc723f98 fix(backup): prevent task creation for empty directories 2024-01-29 07:43:24 +01:00
OlivierFL
8e65ef7dbc feat(xo-web/logs): transform objects UUIDs into clickable links (#7300)
In Settings/Logs modals : transform objects UUIDs into clickable links, leading
to the corresponding object page.
For objects that are not found, UUID can be copied to clipboard.
2024-01-26 17:28:30 +01:00
Mathieu
0c0251082d feat(xo-web/pool): ability to do a rolling pool reboot (#7243)
Fixes #6885
See #7242
2024-01-26 17:08:52 +01:00
Pierre Donias
c250cd9b89 feat(xo-web/VM): ability to add custom notes (#7322)
Fixes #5792
2024-01-26 14:59:32 +01:00
Florent BEAUCHAMP
d6abdb246b feat(xo-server): implement rolling pool reboot (#7242) 2024-01-25 17:50:34 +01:00
Pierre Donias
5769da3ebc feat(xo-web/tags): add tooltips on xo:no-bak and xo:notify-on-snapshot tags (#7335) 2024-01-25 10:27:42 +01:00
Julien Fontanet
4f383635ef feat(xo-server/rest-api): validate params 2024-01-24 11:41:22 +01:00
Julien Fontanet
8a7abc2e54 feat(xo-cli/rest): display error response body 2024-01-24 11:23:43 +01:00
Julien Fontanet
af1650bd14 chore: update dev deps 2024-01-24 10:54:02 +01:00
Julien Fontanet
c6fdef33c4 feat(xo-server): web signin with auth token in query string (#7314)
Potential issue: the token stays in the browser history.
2024-01-24 10:02:38 +01:00
Florent BEAUCHAMP
5f73f09f59 feat(fuse-vhd): implement cli (#7310) 2024-01-23 17:14:18 +01:00
41 changed files with 2004 additions and 1718 deletions

28
@vates/fuse-vhd/.USAGE.md Normal file
View File

@@ -0,0 +1,28 @@
Mount a vhd generated by xen-orchestra to filesystem
### Library
```js
import { mount } from 'fuse-vhd'
// return a disposable, see promise-toolbox/Disposable
// unmount automatically when disposable is disposed
// in case of differencing VHD, it mounts the full chain
await mount(handler, diskId, mountPoint)
```
### cli
From the install folder :
```
cli.mjs <remoteUrl> <vhdPathInRemote> <mountPoint>
```
After installing the package
```
xo-fuse-vhd <remoteUrl> <vhdPathInRemote> <mountPoint>
```
remoteUrl can be found by using cli in `@xen-orchestra/fs` , for example a local remote will have a url like `file:///path/to/remote/root`

59
@vates/fuse-vhd/README.md Normal file
View File

@@ -0,0 +1,59 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/fuse-vhd
[![Package Version](https://badgen.net/npm/v/@vates/fuse-vhd)](https://npmjs.org/package/@vates/fuse-vhd) ![License](https://badgen.net/npm/license/@vates/fuse-vhd) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/fuse-vhd)](https://bundlephobia.com/result?p=@vates/fuse-vhd) [![Node compatibility](https://badgen.net/npm/node/@vates/fuse-vhd)](https://npmjs.org/package/@vates/fuse-vhd)
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/fuse-vhd):
```sh
npm install --save @vates/fuse-vhd
```
## Usage
Mount a vhd generated by xen-orchestra to filesystem
### Library
```js
import { mount } from 'fuse-vhd'
// return a disposable, see promise-toolbox/Disposable
// unmount automatically when disposable is disposed
// in case of differencing VHD, it mounts the full chain
await mount(handler, diskId, mountPoint)
```
### cli
From the install folder :
```
cli.mjs <remoteUrl> <vhdPathInRemote> <mountPoint>
```
After installing the package
```
xo-fuse-vhd <remoteUrl> <vhdPathInRemote> <mountPoint>
```
remoteUrl can be found by using cli in `@xen-orchestra/fs` , for example a local remote will have a url like `file:///path/to/remote/root`
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)

26
@vates/fuse-vhd/cli.mjs Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env node
import Disposable from 'promise-toolbox/Disposable'
import { getSyncedHandler } from '@xen-orchestra/fs'
import { mount } from './index.mjs'
async function* main([remoteUrl, vhdPathInRemote, mountPoint]) {
if (mountPoint === undefined) {
throw new TypeError('missing arg: cli <remoteUrl> <vhdPathInRemote> <mountPoint>')
}
const handler = yield getSyncedHandler({ url: remoteUrl })
const mounted = await mount(handler, vhdPathInRemote, mountPoint)
let disposePromise
process.on('SIGINT', async () => {
// ensure single dispose
if (!disposePromise) {
disposePromise = mounted.dispose()
}
await disposePromise
process.exit()
})
}
Disposable.wrap(main)(process.argv.slice(2))

View File

@@ -19,11 +19,15 @@
},
"main": "./index.mjs",
"dependencies": {
"@xen-orchestra/fs": "^4.1.3",
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.9.0"
},
"bin": {
"xo-fuse-vhd": "./cli.mjs"
},
"scripts": {
"postversion": "npm publish --access public"
}

View File

@@ -86,7 +86,12 @@ export const VmsRemote = class RemoteVmsBackupRunner extends Abstract {
throw new Error(`Job mode ${job.mode} not implemented for mirror backup`)
}
return runTask(taskStart, () => vmBackup.run())
return sourceRemoteAdapter
.listVmBackups(vmUuid, ({ mode }) => mode === job.mode)
.then(vmBackups => {
// avoiding to create tasks for empty directories
if (vmBackups.length > 0) return runTask(taskStart, () => vmBackup.run())
})
}
const { concurrency } = settings
await asyncMapSettled(vmsUuids, !concurrency ? handleVm : limitConcurrency(concurrency)(handleVm))

View File

@@ -17,8 +17,11 @@
- [Host/Reboot] Confirmation modal to reboot an updated slave host if the master is not [#7059](https://github.com/vatesfr/xen-orchestra/issues/7059) (PR [#7293](https://github.com/vatesfr/xen-orchestra/pull/7293))
- [Backup/Restore] Show whether the memory was backed up (PR [#7315](https://github.com/vatesfr/xen-orchestra/pull/7315))
- [Plugin/load-balancer] Limit concurrent VM migrations to 2 (configurable) to avoid long paused VMs [#7084](https://github.com/vatesfr/xen-orchestra/issues/7084) (PR [#7297](https://github.com/vatesfr/xen-orchestra/pull/7297))
- [Plugin/load-balancer] A parameter was added in performance mode to balance VMs on hosts depending on their number of vCPU, when it does not cause performance issues. [#5389](https://github.com/vatesfr/xen-orchestra/issues/5389) (PR [#7333](https://github.com/vatesfr/xen-orchestra/pull/7333))
- [Tags] Admin can create colored tags (PR [#7262](https://github.com/vatesfr/xen-orchestra/pull/7262))
- [Tags] Add tooltips on `xo:no-bak` and `xo:notify-on-snapshot` tags (PR [#7335](https://github.com/vatesfr/xen-orchestra/pull/7335))
- [VM] Custom notes [#5792](https://github.com/vatesfr/xen-orchestra/issues/5792) (PR [#7322](https://github.com/vatesfr/xen-orchestra/pull/7322))
- [Pool/Advanced] Ability to do a `Rolling Pool Reboot` (Enterprise plans) [#6885](https://github.com/vatesfr/xen-orchestra/issues/6885) (PRs [#7243](https://github.com/vatesfr/xen-orchestra/pull/7243), [#7242](https://github.com/vatesfr/xen-orchestra/pull/7242))
- [Settings/Logs] Transform objects UUIDs and OpaqueRefs into clickable links, leading to the corresponding object page (PR [#7300](https://github.com/vatesfr/xen-orchestra/pull/7300))
### Bug fixes
@@ -39,6 +42,7 @@
- [Backup/Restore] Don't count memory as a key (i.e. complete) disk [Forum#8212](https://xcp-ng.org/forum/post/69591) (PR [#7315](https://github.com/vatesfr/xen-orchestra/pull/7315))
- [Pool/patches] Disable Rolling Pool Update button if host is alone in its pool [#6415](https://github.com/vatesfr/xen-orchestra/issues/6415) (PR [#7286](https://github.com/vatesfr/xen-orchestra/pull/7286))
- [PIF] Fix IPv4 reconfiguration only worked when the IPv4 mode was updated (PR [#7324](https://github.com/vatesfr/xen-orchestra/pull/7324))
- [Backup] Removed display of empty directories for mirror backups (PR [#7340](https://github.com/vatesfr/xen-orchestra/pull/7340))
### Packages to release
@@ -57,11 +61,12 @@
<!--packages-start-->
- @vates/decorate-with minor
- @vates/fuse-vhd patch
- @vates/fuse-vhd minor
- @xen-orchestra/backups patch
- @xen-orchestra/self-signed minor
- @xen-orchestra/xapi minor
- xen-api patch
- xo-cli minor
- xo-server minor
- xo-server-load-balancer minor
- xo-web minor

View File

@@ -166,7 +166,7 @@ export async function rest(args) {
return COMMANDS[command].call(
{
exec(path, { query = {}, ...opts } = {}) {
async exec(path, { query = {}, ...opts } = {}) {
const url = new URL(baseUrl)
const i = path.indexOf('?')
@@ -185,7 +185,17 @@ export async function rest(args) {
}
}
return hrp(url, merge({}, baseOpts, opts))
try {
return await hrp(url, merge({}, baseOpts, opts))
} catch (error) {
const { response } = error
if (response === undefined) {
throw error
}
console.error(response.statusCode, response.statusMessage)
throw await response.text()
}
},
json,
},

View File

@@ -24,7 +24,7 @@
"preferGlobal": false,
"main": "dist/",
"engines": {
"node": ">=12.9"
"node": ">=7"
},
"dependencies": {
"@xen-orchestra/cron": "^1.0.6",

View File

@@ -88,20 +88,9 @@ export const configurationSchema = {
$type: 'Tag',
},
},
balanceVcpus: {
type: 'boolean',
title: 'Balance vCPUs',
description: 'pre-position VMs on hosts to balance vCPU/CPU ratio (performance plan only)',
},
},
required: ['name', 'mode', 'pools'],
// when UI will allow it, remove this anyOf and hide balanceVcpu option outside performance mode
anyOf: [
{ properties: { mode: { const: 'Performance mode' } } },
{ properties: { balanceVcpus: { const: false } } },
],
},
minItems: 1,

View File

@@ -45,27 +45,25 @@ export default class PerformancePlan extends Plan {
toOptimizeOnly: true,
})
if (results) {
const { averages, toOptimize } = results
toOptimize.sort((a, b) => -this._sortHosts(a, b))
for (const exceededHost of toOptimize) {
const { id } = exceededHost
debug(`Try to optimize Host (${exceededHost.id}).`)
const availableHosts = filter(hosts, host => host.id !== id)
debug(`Available destinations: ${availableHosts.map(host => host.id)}.`)
// Search bests combinations for the worst host.
await this._optimize({
exceededHost,
hosts: availableHosts,
hostsAverages: averages,
})
}
if (!results) {
return
}
if (this._balanceVcpus) {
await this._processVcpuPrepositionning()
const { averages, toOptimize } = results
toOptimize.sort((a, b) => -this._sortHosts(a, b))
for (const exceededHost of toOptimize) {
const { id } = exceededHost
debug(`Try to optimize Host (${exceededHost.id}).`)
const availableHosts = filter(hosts, host => host.id !== id)
debug(`Available destinations: ${availableHosts.map(host => host.id)}.`)
// Search bests combinations for the worst host.
await this._optimize({
exceededHost,
hosts: availableHosts,
hostsAverages: averages,
})
}
}

View File

@@ -18,12 +18,9 @@ const LOW_THRESHOLD_FACTOR = 0.65
const HIGH_THRESHOLD_MEMORY_FREE_FACTOR = 1.2
const LOW_THRESHOLD_MEMORY_FREE_FACTOR = 1.5
const THRESHOLD_VCPU_RATIO = 0.9
const numberOrDefault = (value, def) => (value >= 0 ? value : def)
export const debugAffinity = str => debug(`anti-affinity: ${str}`)
export const debugVcpuBalancing = str => debug(`vCPU balancing: ${str}`)
// ===================================================================
// Averages.
@@ -99,18 +96,12 @@ function setRealCpuAverageOfVms(vms, vmsAverages, nCpus) {
// ===================================================================
function vcpuPerCpuRatio(host) {
return host.vcpuCount / host.cpuCount
}
// ===================================================================
export default class Plan {
constructor(
xo,
name,
poolIds,
{ excludedHosts, thresholds, balanceVcpus, antiAffinityTags = [] },
{ excludedHosts, thresholds, antiAffinityTags = [] },
globalOptions,
concurrentMigrationLimiter
) {
@@ -128,7 +119,6 @@ export default class Plan {
},
}
this._antiAffinityTags = antiAffinityTags
this._balanceVcpus = balanceVcpus
this._globalOptions = globalOptions
this._concurrentMigrationLimiter = concurrentMigrationLimiter
@@ -286,191 +276,6 @@ export default class Plan {
return vmsAverages
}
// ===================================================================
// vCPU pre-positionning helpers
// ===================================================================
async _processVcpuPrepositionning() {
const promises = []
const allHosts = await this._getHosts()
if (allHosts.length <= 1) {
return
}
const idToHost = keyBy(allHosts, 'id')
const allVms = filter(this._getAllRunningVms(), vm => vm.$container in idToHost)
const hostList = this._getVCPUHosts(allHosts, allVms)
const idealVcpuPerCpuRatio =
hostList.reduce((sum, host) => sum + host.vcpuCount, 0) / hostList.reduce((sum, host) => sum + host.cpuCount, 0)
debugVcpuBalancing('Try to apply vCPU prepositionning.')
debugVcpuBalancing(`vCPU count per host: ${inspect(hostList, { depth: null })}`)
debugVcpuBalancing(`Average vCPUs per CPU: ${idealVcpuPerCpuRatio}`)
// execute prepositionning only if vCPU/CPU ratios are different enough, to prevent executing too often
const ratio = vcpuPerCpuRatio(minBy(hostList, vcpuPerCpuRatio)) / vcpuPerCpuRatio(maxBy(hostList, vcpuPerCpuRatio))
if (ratio > THRESHOLD_VCPU_RATIO) {
debugVcpuBalancing(`vCPU ratios not different enough : ${ratio}`)
return
}
const vmsAverages = await this._getVmsAverages(allVms, idToHost)
const { averages: hostsAverages } = await this._getHostStatsAverages({ hosts: allHosts })
// 1. Find source host from which to migrate.
const sources = sortBy(
filter(hostList, host => (host.vcpuCount - 1) / host.cpuCount >= idealVcpuPerCpuRatio),
[
host => -vcpuPerCpuRatio(host),
// Find host with the most memory used
host => hostsAverages[host.id].memoryFree,
]
)
debugVcpuBalancing(`Sources: ${inspect(sources, { depth: null })}`)
for (const sourceHost of sources) {
let deltaSource = sourceHost.vcpuCount - sourceHost.cpuCount * idealVcpuPerCpuRatio
// deltaSource = 0 has no guaranatee to be reachable, its value can be non-integer
if (deltaSource < 1) {
continue
}
// 2. Find destination host.
const destinations = sortBy(
filter(hostList, host => host.id !== sourceHost.id && host.vcpuCount < host.cpuCount * idealVcpuPerCpuRatio),
[
// trying to avoid migrations between pools
host => host.poolId === sourceHost.poolId,
vcpuPerCpuRatio,
host => -hostsAverages[host.id].memoryFree,
]
)
debugVcpuBalancing(`Destinations : ${inspect(destinations, { depth: null })}`)
if (!destinations.length) {
continue // Cannot find a valid destination.
}
// Build VM list to migrate.
const sourceVms = Object.values(sourceHost.vms)
// eslint-disable-next-line no-labels
destinationLoop: for (const destinationHost of destinations) {
debugVcpuBalancing(`Host candidate: ${sourceHost.id} -> ${destinationHost.id}`)
// calculating how many vCPUs source should give and how many destination should accept
let deltaDestination = destinationHost.vcpuCount - destinationHost.cpuCount * idealVcpuPerCpuRatio
if (
deltaDestination >= 0 ||
hostsAverages[destinationHost.id].cpu >= this._thresholds.cpu.low ||
hostsAverages[destinationHost.id].memoryFree <= this._thresholds.memoryFree.low
) {
continue
}
// avoiding to migrate too much vCPUs for source or destination
// deltaSource is positive, deltaDestination is negative, we check which one has greater absolute value
let delta = deltaSource > -deltaDestination ? Math.ceil(-deltaDestination) : Math.ceil(deltaSource)
const vms = sortBy(
filter(
sourceVms,
vm => hostsAverages[destinationHost.id].memoryFree >= vmsAverages[vm.id].memory && vm.CPUs.number <= delta
),
[vm => -vm.CPUs.number]
)
for (const vm of vms) {
// migrate only if destination is vCPU-underloaded and if this does not cause performance issues
if (
vm.CPUs.number <= delta &&
hostsAverages[destinationHost.id].cpu + vmsAverages[vm.id].cpu < this._thresholds.cpu.low &&
hostsAverages[destinationHost.id].memoryFree - vmsAverages[vm.id].memory > this._thresholds.memoryFree.low
) {
const source = idToHost[sourceHost.id]
const destination = idToHost[destinationHost.id]
debugVcpuBalancing(
`Migrate VM (${vm.id} "${vm.name_label}") with ${vm.CPUs.number} vCPU to Host (${destinationHost.id} "${destination.name_label}") from Host (${sourceHost.id} "${source.name_label}").`
)
// 3. Update tags and averages.
// This update can change the source host for the next migration.
sourceHost.vcpuCount -= vm.CPUs.number
destinationHost.vcpuCount += vm.CPUs.number
const destinationAverages = hostsAverages[destinationHost.id]
const vmAverages = vmsAverages[vm.id]
destinationAverages.cpu += vmAverages.cpu
destinationAverages.memoryFree -= vmAverages.memory
delete sourceHost.vms[vm.id]
// 4. Migrate.
const sourceXapi = this.xo.getXapi(source)
promises.push(
this._concurrentMigrationLimiter.call(
sourceXapi,
'migrateVm',
vm._xapiId,
this.xo.getXapi(destination),
destination._xapiId
)
)
debugVcpuBalancing(`vCPU count per host: ${inspect(hostList, { depth: null })}`)
// 5. Check if source host is still overloaded and if destination host is still underloaded
deltaSource = sourceHost.vcpuCount - sourceHost.cpuCount * idealVcpuPerCpuRatio
if (deltaSource < 1) {
// eslint-disable-next-line no-labels
break destinationLoop
}
deltaDestination = destinationHost.vcpuCount - destinationHost.cpuCount * idealVcpuPerCpuRatio
if (deltaDestination >= 0) {
break
}
delta = deltaSource > -deltaDestination ? Math.ceil(-deltaDestination) : Math.ceil(deltaSource)
}
}
}
}
return Promise.allSettled(promises)
}
_getVCPUHosts(hosts, vms) {
const idToHost = {}
for (const host of hosts) {
const taggedHost = (idToHost[host.id] = {
id: host.id,
poolId: host.$poolId,
cpuCount: parseInt(host.CPUs.cpu_count),
vcpuCount: 0,
vms: {},
})
// Hide properties when util.inspect is used.
Object.defineProperties(taggedHost, {
poolId: { enumerable: false },
vms: { enumerable: false },
})
}
for (const vm of vms) {
const hostId = vm.$container
if (!(hostId in idToHost)) {
continue
}
const host = idToHost[hostId]
host.vcpuCount += vm.CPUs.number
if (vm.xenTools && vm.tags.every(tag => !this._antiAffinityTags.includes(tag))) {
host.vms[vm.id] = vm
}
}
return Object.values(idToHost)
}
// ===================================================================
// Anti-affinity helpers
// ===================================================================

View File

@@ -200,6 +200,31 @@ rollingUpdate.resolve = {
// -------------------------------------------------------------------
export async function rollingReboot({ bypassBackupCheck, pool }) {
const poolId = pool.id
if (bypassBackupCheck) {
log.warn('pool.rollingReboot update with argument "bypassBackupCheck" set to true', { poolId })
} else {
await backupGuard.call(this, poolId)
}
await this.rollingPoolReboot(pool)
}
rollingReboot.params = {
bypassBackupCheck: {
default: false,
type: 'boolean',
},
pool: { type: 'string' },
}
rollingReboot.resolve = {
pool: ['pool', 'pool', 'administrate'],
}
// -------------------------------------------------------------------
export async function getPatchesDifference({ source, target }) {
return this.getPatchesDifference(target.id, source.id)
}

View File

@@ -694,6 +694,8 @@ set.params = {
name_description: { type: 'string', minLength: 0, optional: true },
notes: { type: ['string', 'null'], maxLength: 2048, optional: true },
high_availability: {
optional: true,
enum: getHaValues(),

View File

@@ -170,13 +170,31 @@ async function setUpPassport(express, xo, { authentication: authCfg, http: { coo
// Registers the sign in form.
const signInPage = compilePug(await fse.readFile(new URL('../signin.pug', import.meta.url)))
express.get('/signin', (req, res, next) => {
res.send(
signInPage({
error: req.flash('error')[0],
strategies,
})
)
express.get('/signin', async (req, res, next) => {
try {
let errorMsg
const tokenId = req.query.token
if (tokenId !== undefined) {
try {
const token = await xo.getAuthenticationToken(tokenId)
req.session.isPersistent = req.query
return saveToken(token, req, res, next)
} catch (error) {
errorMsg = error.message
}
} else {
errorMsg = req.flash('error')[0]
}
res.send(
signInPage({
error: errorMsg,
strategies,
})
)
} catch (error) {
next(error)
}
})
express.get('/signout', (req, res) => {
@@ -206,7 +224,7 @@ async function setUpPassport(express, xo, { authentication: authCfg, http: { coo
}
if (await verifyTotp(req.body.otp, { secret: user.preferences.otp })) {
setToken(req, res, next)
createAndSaveToken(req, res, next)
} else {
req.flash('error', 'Invalid code')
res.redirect(303, '/signin-otp')
@@ -216,7 +234,7 @@ async function setUpPassport(express, xo, { authentication: authCfg, http: { coo
const PERMANENT_VALIDITY = ifDef(authCfg.permanentCookieValidity, parseDuration)
const SESSION_VALIDITY = ifDef(authCfg.sessionCookieValidity, parseDuration)
const TEN_YEARS = 10 * 365 * 24 * 60 * 60 * 1e3
const setToken = async (req, res, next) => {
const createAndSaveToken = async (req, res, next) => {
let { clientId } = req.cookies
if (clientId === undefined) {
clientId = Math.random().toString(36).slice(2)
@@ -237,17 +255,20 @@ async function setUpPassport(express, xo, { authentication: authCfg, http: { coo
expiresIn: isPersistent ? PERMANENT_VALIDITY : SESSION_VALIDITY,
userId: user.id,
})
delete req.session.user
return saveToken(token, req, res, next)
}
const saveToken = async (token, req, res, next) => {
res.cookie('token', token.id, {
...cookieCfg,
// a session (non-permanent) cookie must not have an expiration date
// because it must not survive browser restart
...(isPersistent ? { expires: new Date(token.expiration) } : undefined),
...(req.session.isPersistent ? { expires: new Date(token.expiration) } : undefined),
})
delete req.session.isPersistent
delete req.session.user
res.redirect(303, req.flash('return-url')[0] || '/')
}
@@ -288,7 +309,7 @@ async function setUpPassport(express, xo, { authentication: authCfg, http: { coo
return res.redirect(303, '/signin-otp')
}
setToken(req, res, next)
createAndSaveToken(req, res, next)
})(req, res, next)
}

View File

@@ -401,6 +401,7 @@ const TRANSFORMS = {
installTime: metrics && toTimestamp(metrics.install_time),
name_description: obj.name_description,
name_label: obj.name_label,
notes: otherConfig['xo:notes'],
other: otherConfig,
os_version: (guestMetrics && guestMetrics.os_version) || null,
parent: link(obj, 'parent'),

View File

@@ -1,7 +1,5 @@
import filter from 'lodash/filter.js'
import find from 'lodash/find.js'
import groupBy from 'lodash/groupBy.js'
import mapValues from 'lodash/mapValues.js'
import pickBy from 'lodash/pickBy.js'
import some from 'lodash/some.js'
import unzip from 'unzipper'
@@ -9,15 +7,13 @@ import { asyncEach } from '@vates/async-each'
import { createLogger } from '@xen-orchestra/log'
import { decorateObject } from '@vates/decorate-with'
import { defer as deferrable } from 'golike-defer'
import { incorrectState } from 'xo-common/api-errors.js'
import { extractOpaqueRef, parseDateTime } from '@xen-orchestra/xapi'
import { timeout } from 'promise-toolbox'
import { extractOpaqueRef } from '@xen-orchestra/xapi'
import ensureArray from '../../_ensureArray.mjs'
import { debounceWithKey } from '../../_pDebounceWithKey.mjs'
import { forEach, mapFilter, parseXml } from '../../utils.mjs'
import { isHostRunning, useUpdateSystem } from '../utils.mjs'
import { useUpdateSystem } from '../utils.mjs'
// TOC -------------------------------------------------------------------------
@@ -494,163 +490,33 @@ const methods = {
async rollingPoolUpdate($defer, { xsCredentials } = {}) {
const isXcp = _isXcp(this.pool.$master)
if (this.pool.ha_enabled) {
const haSrs = this.pool.$ha_statefiles.map(vdi => vdi.SR)
const haConfig = this.pool.ha_configuration
await this.call('pool.disable_ha')
$defer(() => this.call('pool.enable_ha', haSrs, haConfig))
}
const hosts = filter(this.objects.all, { $type: 'host' })
{
const deadHost = hosts.find(_ => !isHostRunning(_))
if (deadHost !== undefined) {
// reflect the interface of an XO host object
throw incorrectState({
actual: 'Halted',
expected: 'Running',
object: deadHost.$id,
property: 'power_state',
})
}
}
await Promise.all(hosts.map(host => host.$call('assert_can_evacuate')))
const hasMissingPatchesByHost = {}
const hosts = filter(this.objects.all, { $type: 'host' })
await asyncEach(hosts, async host => {
const hostUuid = host.uuid
const missingPatches = await this.listMissingPatches(hostUuid)
hasMissingPatchesByHost[hostUuid] = missingPatches.length > 0
})
// On XS/CH, start by installing patches on all hosts
if (!isXcp) {
log.debug('Install patches')
await this.installPatches({ xsCredentials })
}
// Remember on which hosts the running VMs are
const vmRefsByHost = mapValues(
groupBy(
filter(this.objects.all, {
$type: 'VM',
power_state: 'Running',
is_control_domain: false,
}),
vm => {
const hostId = vm.$resident_on?.$id
if (hostId === undefined) {
throw new Error('Could not find host of all running VMs')
}
return hostId
await this.rollingPoolReboot({
xsCredentials,
beforeEvacuateVms: async () => {
// On XS/CH, start by installing patches on all hosts
if (!isXcp) {
log.debug('Install patches')
await this.installPatches({ xsCredentials })
}
),
vms => vms.map(vm => vm.$ref)
)
// Put master in first position to restart it first
const indexOfMaster = hosts.findIndex(host => host.$ref === this.pool.master)
if (indexOfMaster === -1) {
throw new Error('Could not find pool master')
}
;[hosts[0], hosts[indexOfMaster]] = [hosts[indexOfMaster], hosts[0]]
// Restart all the hosts one by one
for (const host of hosts) {
const hostId = host.uuid
if (!hasMissingPatchesByHost[hostId]) {
continue
}
// This is an old metrics reference from before the pool master restart.
// The references don't seem to change but it's not guaranteed.
const metricsRef = host.metrics
await this.barrier(metricsRef)
await this._waitObjectState(metricsRef, metrics => metrics.live)
const getServerTime = async () => parseDateTime(await this.call('host.get_servertime', host.$ref)) * 1e3
let rebootTime
if (isXcp) {
// On XCP-ng, install patches on each host one by one instead of all at once
log.debug(`Evacuate host ${hostId}`)
await this.clearHost(host)
log.debug(`Install patches on host ${hostId}`)
await this.installPatches({ hosts: [host] })
log.debug(`Restart host ${hostId}`)
rebootTime = await getServerTime()
await this.callAsync('host.reboot', host.$ref)
} else {
// On XS/CH, we only need to evacuate/restart the hosts one by one since patches have already been installed
log.debug(`Evacuate and restart host ${hostId}`)
rebootTime = await getServerTime()
await this.rebootHost(hostId)
}
log.debug(`Wait for host ${hostId} to be up`)
await timeout.call(
(async () => {
await this._waitObjectState(
hostId,
host => host.enabled && rebootTime < host.other_config.agent_start_time * 1e3
)
await this._waitObjectState(metricsRef, metrics => metrics.live)
})(),
this._restartHostTimeout,
new Error(`Host ${hostId} took too long to restart`)
)
log.debug(`Host ${hostId} is up`)
}
if (some(hasMissingPatchesByHost)) {
log.debug('Migrate VMs back to where they were')
}
// Start with the last host since it's the emptiest one after the rolling
// update
;[hosts[0], hosts[hosts.length - 1]] = [hosts[hosts.length - 1], hosts[0]]
let error
for (const host of hosts) {
const hostId = host.uuid
if (!hasMissingPatchesByHost[hostId]) {
continue
}
const vmRefs = vmRefsByHost[hostId]
if (vmRefs === undefined) {
continue
}
// host.$resident_VMs is outdated and returns resident VMs before the host.evacuate.
// this.getField is used in order not to get cached data.
const residentVmRefs = await this.getField('host', host.$ref, 'resident_VMs')
for (const vmRef of vmRefs) {
if (residentVmRefs.includes(vmRef)) {
continue
},
beforeRebootHost: async host => {
if (isXcp) {
log.debug(`Install patches on host ${host.id}`)
await this.installPatches({ hosts: [host] })
}
try {
const vmId = await this.getField('VM', vmRef, 'uuid')
await this.migrateVm(vmId, this, hostId)
} catch (err) {
log.error(err)
if (error === undefined) {
error = err
}
}
}
}
if (error !== undefined) {
throw error
}
},
ignoreHost: host => {
return !hasMissingPatchesByHost[host.uuid]
},
})
},
}

View File

@@ -1,7 +1,16 @@
import { cancelable, timeout } from 'promise-toolbox'
import { createLogger } from '@xen-orchestra/log'
import { decorateObject } from '@vates/decorate-with'
import { cancelable } from 'promise-toolbox'
import { defer as deferrable } from 'golike-defer'
import { incorrectState } from 'xo-common/api-errors.js'
import { isHostRunning } from '../utils.mjs'
import { parseDateTime } from '@xen-orchestra/xapi'
import filter from 'lodash/filter.js'
import groupBy from 'lodash/groupBy.js'
import mapValues from 'lodash/mapValues.js'
const PATH_DB_DUMP = '/pool/xmldbdump'
const log = createLogger('xo:xapi')
const methods = {
exportPoolMetadata($cancelToken) {
@@ -21,9 +30,156 @@ const methods = {
task: this.task_create('Import pool metadata'),
})
},
async rollingPoolReboot($defer, { beforeEvacuateVms, beforeRebootHost, ignoreHost } = {}) {
if (this.pool.ha_enabled) {
const haSrs = this.pool.$ha_statefiles.map(vdi => vdi.SR)
const haConfig = this.pool.ha_configuration
await this.call('pool.disable_ha')
$defer(() => this.call('pool.enable_ha', haSrs, haConfig))
}
const hosts = filter(this.objects.all, { $type: 'host' })
{
const deadHost = hosts.find(_ => !isHostRunning(_))
if (deadHost !== undefined) {
// reflect the interface of an XO host object
throw incorrectState({
actual: 'Halted',
expected: 'Running',
object: deadHost.$id,
property: 'power_state',
})
}
}
await Promise.all(hosts.map(host => host.$call('assert_can_evacuate')))
if (beforeEvacuateVms) {
await beforeEvacuateVms()
}
// Remember on which hosts the running VMs are
const vmRefsByHost = mapValues(
groupBy(
filter(this.objects.all, {
$type: 'VM',
power_state: 'Running',
is_control_domain: false,
}),
vm => {
const hostId = vm.$resident_on?.$id
if (hostId === undefined) {
throw new Error('Could not find host of all running VMs')
}
return hostId
}
),
vms => vms.map(vm => vm.$ref)
)
// Put master in first position to restart it first
const indexOfMaster = hosts.findIndex(host => host.$ref === this.pool.master)
if (indexOfMaster === -1) {
throw new Error('Could not find pool master')
}
;[hosts[0], hosts[indexOfMaster]] = [hosts[indexOfMaster], hosts[0]]
let hasRestartedOne = false
// Restart all the hosts one by one
for (const host of hosts) {
const hostId = host.uuid
if (ignoreHost && ignoreHost(host)) {
continue
}
// This is an old metrics reference from before the pool master restart.
// The references don't seem to change but it's not guaranteed.
const metricsRef = host.metrics
await this.barrier(metricsRef)
await this._waitObjectState(metricsRef, metrics => metrics.live)
const getServerTime = async () => parseDateTime(await this.call('host.get_servertime', host.$ref)) * 1e3
log.debug(`Evacuate host ${hostId}`)
await this.clearHost(host)
if (beforeRebootHost) {
await beforeRebootHost(host)
}
log.debug(`Restart host ${hostId}`)
const rebootTime = await getServerTime()
await this.callAsync('host.reboot', host.$ref)
log.debug(`Wait for host ${hostId} to be up`)
await timeout.call(
(async () => {
await this._waitObjectState(
hostId,
host => host.enabled && rebootTime < host.other_config.agent_start_time * 1e3
)
await this._waitObjectState(metricsRef, metrics => metrics.live)
})(),
this._restartHostTimeout,
new Error(`Host ${hostId} took too long to restart`)
)
log.debug(`Host ${hostId} is up`)
hasRestartedOne = true
}
if (hasRestartedOne) {
log.debug('Migrate VMs back to where they were')
}
// Start with the last host since it's the emptiest one after the rolling
// update
;[hosts[0], hosts[hosts.length - 1]] = [hosts[hosts.length - 1], hosts[0]]
let error
for (const host of hosts) {
const hostId = host.uuid
if (ignoreHost && ignoreHost(host)) {
continue
}
const vmRefs = vmRefsByHost[hostId]
if (vmRefs === undefined) {
continue
}
// host.$resident_VMs is outdated and returns resident VMs before the host.evacuate.
// this.getField is used in order not to get cached data.
const residentVmRefs = await this.getField('host', host.$ref, 'resident_VMs')
for (const vmRef of vmRefs) {
if (residentVmRefs.includes(vmRef)) {
continue
}
try {
const vmId = await this.getField('VM', vmRef, 'uuid')
await this.migrateVm(vmId, this, hostId)
} catch (err) {
log.error(err)
if (error === undefined) {
error = err
}
}
}
}
if (error !== undefined) {
throw error
}
},
}
export default decorateObject(methods, {
exportPoolMetadata: cancelable,
importPoolMetadata: cancelable,
rollingPoolReboot: deferrable,
})

View File

@@ -382,6 +382,11 @@ const methods = {
nameLabel: true,
notes: {
get: vm => vm.other_config['xo:notes'],
set: (value, vm) => vm.update_other_config('xo:notes', value),
},
PV_args: true,
tags: true,

View File

@@ -0,0 +1,95 @@
import Ajv from 'ajv'
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true, useDefaults: true })
function makeIsType({ type }) {
if (typeof type === 'string') {
return t => t === type
}
const types = new Set(type)
return t => types.has(t)
}
/**
* Transform an XO JSON schema to a standard JSON schema
*
* Differences of XO JSON schemas:
* - objects:
* - properties are required by default
* - properties can be marked as `optional` in place of listing them with `required`
* - additional properties disabled by default
* - a wildcard `*` property can be used in place of `additionalProperties`
* - strings must be non emtpy by default
*/
function xoToJsonSchema(schema) {
if (schema.enum !== undefined) {
return schema
}
const is = makeIsType(schema)
if (is('array')) {
const { items } = schema
if (items !== undefined) {
if (Array.isArray(items)) {
for (let i = 0, n = items.length; i < n; ++i) {
items[i] = xoToJsonSchema(items[i])
}
} else {
schema.items = xoToJsonSchema(items)
}
}
}
if (is('object')) {
const { properties = {} } = schema
let keys = Object.keys(properties)
for (const key of keys) {
properties[key] = xoToJsonSchema(properties[key])
}
const { additionalProperties } = schema
if (additionalProperties === undefined) {
const wildCard = properties['*']
if (wildCard === undefined) {
// we want additional properties to be disabled by default unless no properties are defined
schema.additionalProperties = keys.length === 0
} else {
delete properties['*']
keys = Object.keys(properties)
schema.additionalProperties = wildCard
}
} else if (typeof additionalProperties === 'object') {
schema.additionalProperties = xoToJsonSchema(additionalProperties)
}
// we want properties to be required by default unless explicitly marked so
// we use property `optional` instead of object `required`
if (schema.required === undefined) {
const required = keys.filter(key => {
const value = properties[key]
const required = !value.optional
delete value.optional
return required
})
if (required.length !== 0) {
schema.required = required
}
}
}
if (is('string')) {
// we want strings to be not empty by default
if (schema.minLength === undefined && schema.format === undefined && schema.pattern === undefined) {
schema.minLength = 1
}
}
return schema
}
export function compileXoJsonSchema(schema) {
return ajv.compile(xoToJsonSchema(schema))
}

View File

@@ -1,7 +1,6 @@
import emitAsync from '@xen-orchestra/emit-async'
import { createLogger } from '@xen-orchestra/log'
import Ajv from 'ajv'
import cloneDeep from 'lodash/cloneDeep.js'
import forEach from 'lodash/forEach.js'
import kindOf from 'kindof'
@@ -15,6 +14,7 @@ import Connection from '../connection.mjs'
import { noop, serializeError } from '../utils.mjs'
import * as errors from 'xo-common/api-errors.js'
import { compileXoJsonSchema } from './_xoJsonSchema.mjs'
// ===================================================================
@@ -56,8 +56,6 @@ const XAPI_ERROR_TO_XO_ERROR = {
const hasPermission = (actual, expected) => PERMISSIONS[actual] >= PERMISSIONS[expected]
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true, useDefaults: true })
function checkParams(method, params) {
// Parameters suffixed by `?` are marked as ignorable by the client and
// ignored if unsupported by this version of the API
@@ -117,80 +115,6 @@ function checkPermission(method) {
}
}
function adaptJsonSchema(schema) {
if (schema.enum !== undefined) {
return schema
}
const is = (({ type }) => {
if (typeof type === 'string') {
return t => t === type
}
const types = new Set(type)
return t => types.has(t)
})(schema)
if (is('array')) {
const { items } = schema
if (items !== undefined) {
if (Array.isArray(items)) {
for (let i = 0, n = items.length; i < n; ++i) {
items[i] = adaptJsonSchema(items[i])
}
} else {
schema.items = adaptJsonSchema(items)
}
}
}
if (is('object')) {
const { properties = {} } = schema
let keys = Object.keys(properties)
for (const key of keys) {
properties[key] = adaptJsonSchema(properties[key])
}
const { additionalProperties } = schema
if (additionalProperties === undefined) {
const wildCard = properties['*']
if (wildCard === undefined) {
// we want additional properties to be disabled by default unless no properties are defined
schema.additionalProperties = keys.length === 0
} else {
delete properties['*']
keys = Object.keys(properties)
schema.additionalProperties = wildCard
}
} else if (typeof additionalProperties === 'object') {
schema.additionalProperties = adaptJsonSchema(additionalProperties)
}
// we want properties to be required by default unless explicitly marked so
// we use property `optional` instead of object `required`
if (schema.required === undefined) {
const required = keys.filter(key => {
const value = properties[key]
const required = !value.optional
delete value.optional
return required
})
if (required.length !== 0) {
schema.required = required
}
}
}
if (is('string')) {
// we want strings to be not empty by default
if (schema.minLength === undefined && schema.format === undefined && schema.pattern === undefined) {
schema.minLength = 1
}
}
return schema
}
async function resolveParams(method, params) {
const resolve = method.resolve
if (!resolve) {
@@ -298,15 +222,12 @@ export default class Api {
let validate
if (params !== undefined) {
let schema = { type: 'object', properties: cloneDeep(params) }
try {
schema = adaptJsonSchema(schema)
validate = ajv.compile(schema)
validate = compileXoJsonSchema({ type: 'object', properties: cloneDeep(params) })
} catch (error) {
log.warn('failed to compile method params schema', {
error,
method: name,
schema,
})
throw error
}

View File

@@ -33,6 +33,7 @@ const AUTHORIZATIONS = {
LIST_MISSING_PATCHES: STARTER,
POOL_EMERGENCY_SHUTDOWN: ENTERPRISE,
ROLLING_POOL_UPDATE: ENTERPRISE,
ROLLING_POOL_REBOOT: ENTERPRISE,
}
export default class Authorization {

View File

@@ -134,4 +134,10 @@ export default class Pools {
srsByPool[pool.id].some(sr => sr.size - sr.physical_usage >= minAvailableSrSize && checkSrName(sr.name_label))
)
}
async rollingPoolReboot(pool) {
const { _app } = this
await _app.checkFeatureAuthorization('ROLLING_POOL_REBOOT')
await _app.getXapi(pool).rollingPoolReboot()
}
}

View File

@@ -5,12 +5,14 @@ import { ifDef } from '@xen-orchestra/defined'
import { featureUnauthorized, invalidCredentials, noSuchObject } from 'xo-common/api-errors.js'
import { pipeline } from 'node:stream/promises'
import { json, Router } from 'express'
import cloneDeep from 'lodash/cloneDeep.js'
import path from 'node:path'
import pick from 'lodash/pick.js'
import * as CM from 'complex-matcher'
import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from '@xen-orchestra/xapi'
import { getUserPublicProperties } from '../utils.mjs'
import { compileXoJsonSchema } from './_xoJsonSchema.mjs'
const { join } = path.posix
const noop = Function.prototype
@@ -227,6 +229,11 @@ export default class RestApi {
},
}
const withParams = (fn, paramsSchema) => {
fn.validateParams = compileXoJsonSchema({ type: 'object', properties: cloneDeep(paramsSchema) })
return fn
}
collections.pools.actions = {
__proto__: null,
@@ -248,10 +255,13 @@ export default class RestApi {
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: async ({ xapiObject: vm }, { name_label }) => {
const ref = await vm.$snapshot({ name_label })
return vm.$xapi.getField('VM', ref, 'uuid')
},
snapshot: withParams(
async ({ xapiObject: vm }, { name_label }) => {
const ref = await vm.$snapshot({ name_label })
return vm.$xapi.getField('VM', ref, 'uuid')
},
{ name_label: { type: 'string', optional: true } }
),
start: ({ xapiObject: vm }) => vm.$callAsync('start', false, false).then(noop),
}
@@ -594,9 +604,19 @@ export default class RestApi {
return next()
}
const params = req.body
const { validateParams } = fn
if (validateParams !== undefined) {
if (!validateParams(params)) {
res.statusCode = 400
return res.json(validateParams.errors)
}
}
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 }, req.body))
const pResult = task.run(() => fn({ xapiObject, xoObject }, params))
if (Object.hasOwn(req.query, 'sync')) {
pResult.then(result => res.json(result), next)
} else {

View File

@@ -641,7 +641,7 @@ export default class XenServers {
async rollingPoolUpdate($defer, pool) {
const app = this._app
await app.checkFeatureAuthorization('ROLLING_POOL_UPDATE')
const [schedules, jobs] = await Promise.all([app.getAllSchedules(), app.getAllJobs('backup')])
const poolId = pool.id

View File

@@ -123,6 +123,7 @@
"relative-luminance": "^2.0.1",
"reselect": "^2.5.4",
"rimraf": "^5.0.1",
"sanitize-html": "^2.11.0",
"sass": "^1.38.1",
"semver": "^6.0.0",
"strip-ansi": "^5.2.0",

View File

@@ -7,3 +7,9 @@
.container:hover .button {
visibility: visible;
}
:global(.modal-body) .container .button {
position: relative;
margin-left: -4ex;
left: 3.5em;
}

View File

@@ -18,7 +18,6 @@ const Copiable = ({ className, tagName = 'span', ...props }) =>
className: classNames(styles.container, className),
},
props.children,
' ',
<Tooltip content={_('copyToClipboard')}>
<CopyToClipboard text={props.data || props.children}>
<Button className={styles.button} size='small'>

View File

@@ -912,6 +912,13 @@ const messages = {
poolSupportSourceUsers: 'XCP-ng Pro Support not available for source users',
poolSupportXcpngOnly: 'Only available for pool of XCP-ng hosts',
poolLicenseAlreadyFullySupported: 'The pool is already fully supported',
rollingPoolReboot: 'Rolling Pool Reboot',
rollingPoolRebootHaWarning: 'High Availability is enabled. This will automatically disable it during the reboot.',
rollingPoolRebootLoadBalancerWarning:
'Load Balancer plugin is running. This will automatically pause it during the reboot.',
rollingPoolRebootMessage:
'Are you sure you want to start a Rolling Pool Reboot? Running VMs will be migrated back and forth and this can take a while. Scheduled backups that may concern this pool will be disabled.',
setpoolMaster: 'Master',
syslogRemoteHost: 'Remote syslog host',
defaultMigrationNetwork: 'Default migration network',
@@ -1197,6 +1204,9 @@ const messages = {
'Enabling this will allow the VM to automatically install Citrix PV drivers from Windows Update. This only includes drivers, the Citrix management agent must still be separately installed.',
windowsToolsModalWarning:
'If you have previously installed XCP-ng tools instead of Citrix tools, this option will break your VM.',
editVmNotes: 'Edit VM notes',
supportsMarkdown: 'Supports Markdown syntax',
vmNotesTooLong: 'VM notes cannot be longer than 2048 characters',
// ----- VM stat tab -----
statsCpu: 'CPU usage',
@@ -1853,6 +1863,8 @@ const messages = {
remoteLoadBackupsFailure: 'Loading backups failed',
remoteLoadBackupsFailureMessage: 'Failed to load backups from {name}.',
vmsTags: 'VMs tags',
tagNoBak: 'VMs with this tag will not be backed up {reason, select, null {} other {({reason})}}',
tagNotifyOnSnapshot: 'An email will be sent when a VM with this tag is snapshotted',
// ----- Restore files view -----
restoreFiles: 'Restore backup files',
@@ -2679,6 +2691,9 @@ const messages = {
proxyMultipleLicenses: 'This proxy has more than 1 license!',
proxyUnknownVm: 'Unknown proxy VM.',
// ----- plan -----
onlyAvailableToEnterprise: 'Only available to Enterprise users',
// ----- proxies -----
forgetProxyApplianceTitle: 'Forget prox{n, plural, one {y} other {ies}}',
forgetProxyApplianceMessage: 'Are you sure you want to forget {n, number} prox{n, plural, one {y} other {ies}}?',

View File

@@ -0,0 +1,79 @@
import React from 'react'
import { connectStore } from 'utils'
import { createGetObjectsOfType } from 'selectors'
import PropTypes from 'prop-types'
import decorate from 'apply-decorators'
import { injectState, provideState } from 'reaclette'
import Copiable from 'copiable'
import { flatMap } from 'lodash'
import Link from 'link'
import store from 'store'
/**
* TODO : check user permissions on objects retrieved by refs, if using the component in non-admin pages
*/
const RichText = decorate([
connectStore({
vms: createGetObjectsOfType('VM'),
hosts: createGetObjectsOfType('host'),
pools: createGetObjectsOfType('pool'),
srs: createGetObjectsOfType('SR'),
}),
provideState({
computed: {
idToLink: (_, props) => {
const regex = /\b(?:OpaqueRef:)?[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\b/g
const parts = props.message.split(regex)
const ids = props.message.match(regex) || []
const { objects } = store.getState()
return flatMap(parts, (part, index) => {
// If on last part, return only the part without adding Copiable component
if (index === ids.length) {
return part
}
const id = ids[index]
let _object
for (const collection of [props.vms, props.hosts, props.pools, props.srs]) {
_object = id.startsWith('OpaqueRef:') ? objects.byRef.get(id) : collection[id]
if (_object !== undefined) break
}
if (_object !== undefined && ['VM', 'host', 'pool', 'SR'].includes(_object.type)) {
return [
part,
<Link key={index} to={`/${_object.type.toLowerCase()}s/${_object.uuid}`}>
{id}
</Link>,
]
} else {
return [part, <Copiable key={index}>{id}</Copiable>]
}
})
},
},
}),
injectState,
({ state: { idToLink }, copiable, message }) =>
copiable ? (
<Copiable tagName='pre' data={message}>
{idToLink}
</Copiable>
) : (
<pre>{idToLink}</pre>
),
])
RichText.propTypes = {
message: PropTypes.string,
copiable: PropTypes.bool,
}
RichText.defaultProps = {
copiable: false,
}
export default RichText

View File

@@ -119,11 +119,12 @@ export default {
objects: combineActionHandlers(
{
all: {}, // Mutable for performance!
byRef: new Map(), // Mutable for performance!
byType: {},
fetched: false,
},
{
[actions.updateObjects]: ({ all, byType: prevByType, fetched }, updates) => {
[actions.updateObjects]: ({ all, byRef, byType: prevByType, fetched }, updates) => {
const byType = { ...prevByType }
const get = type => {
const curr = byType[type]
@@ -139,6 +140,7 @@ export default {
const { type } = object
all[id] = object
byRef.set(object._xapiRef, object)
get(type)[id] = object
if (previous && previous.type !== type) {
@@ -147,10 +149,11 @@ export default {
} else if (previous) {
delete all[id]
delete get(previous.type)[id]
byRef.delete(previous._xapiRef)
}
}
return { all, byType, fetched }
return { all, byRef, byType, fetched }
},
[actions.markObjectsFetched]: state => ({
...state,

View File

@@ -251,6 +251,11 @@ export default class Tags extends Component {
}
}
const TAG_TO_MESSAGE_ID = {
'xo:no-bak': 'tagNoBak',
'xo:notify-on-snapshot': 'tagNotifyOnSnapshot',
}
@addSubscriptions({
configuredTags: cb => subscribeConfiguredTags(tags => cb(keyBy(tags, 'id'))),
})
@@ -272,6 +277,11 @@ export class Tag extends Component {
const i = label.indexOf('=')
const isScoped = i !== -1
const scope = isScoped ? label.slice(0, i) : label
const reason = isScoped ? label.slice(i + 1) : null
const messageId = TAG_TO_MESSAGE_ID[scope]
return (
<div
style={{
@@ -286,6 +296,19 @@ export class Tag extends Component {
overflow: 'clip',
}}
>
{messageId && (
<div
style={{
cursor: 'help',
display: 'inline-block',
padding,
}}
>
<Tooltip content={_(messageId, { reason })}>
<Icon icon='info' />
</Tooltip>
</div>
)}
<div
onClick={onClick && (() => onClick(label))}
style={{
@@ -299,7 +322,7 @@ export class Tag extends Component {
padding,
}}
>
{isScoped ? label.slice(0, i) : label}
{scope}
</div>
{isScoped && (
<div
@@ -310,7 +333,7 @@ export class Tag extends Component {
padding,
}}
>
{label.slice(i + 1) || <i>N/A</i>}
{reason || <i>N/A</i>}
</div>
)}
</div>

View File

@@ -0,0 +1,36 @@
import _ from 'intl'
import Icon from 'icon'
import clamp from 'lodash/clamp'
import Component from 'base-component'
import React from 'react'
export default class EditVmNotesModalBody extends Component {
get value() {
return { notes: this.state.notes ?? this.props.vm.notes ?? '' }
}
render() {
return (
<div>
<textarea
autoFocus
rows={clamp(this.value.notes.split('\n').length, 5, 20)}
onChange={this.linkState('notes')}
value={this.value.notes}
className='form-control'
/>
{this.value.notes.length > 2048 && (
<em className='text-warning'>
<Icon icon='alarm' /> {_('vmNotesTooLong')}
</em>
)}
<em>
<Icon icon='info' />{' '}
<a href='https://commonmark.org/help/' target='_blank' rel='noreferrer'>
{_('supportsMarkdown')}
</a>
</em>
</div>
)
}
}

View File

@@ -48,6 +48,7 @@ import {
} from '../store/actions'
import parseNdJson from './_parseNdJson'
import RollingPoolRebootModal from './rolling-pool-reboot-modal'
// ===================================================================
@@ -816,6 +817,32 @@ export const setPoolMaster = host =>
}),
}).then(() => _call('pool.setPoolMaster', { host: resolveId(host) }), noop)
export const rollingPoolReboot = async pool => {
const poolId = resolveId(pool)
await confirm({
body: <RollingPoolRebootModal pool={poolId} />,
title: _('rollingPoolReboot'),
icon: 'pool-rolling-reboot',
})
try {
return await _call('pool.rollingReboot', { pool: poolId })
} catch (error) {
if (!forbiddenOperation.is(error)) {
throw error
}
await confirm({
body: (
<p className='text-warning'>
<Icon icon='alarm' /> {_('bypassBackupPoolModalMessage')}
</p>
),
title: _('rollingPoolReboot'),
icon: 'pool-rolling-reboot',
})
return _call('pool.rollingReboot', { pool: poolId, bypassBackupCheck: true })
}
}
// Host --------------------------------------------------------------
export const setSchedulerGranularity = (host, schedulerGranularity) =>
@@ -1524,6 +1551,18 @@ export const changeVirtualizationMode = vm =>
})
)
import EditVmNotesModalBody from './edit-vm-notes-modal' // eslint-disable-line import/first
export const editVmNotes = async vm => {
const { notes } = await confirm({
icon: 'edit',
title: _('editVmNotes'),
body: <EditVmNotesModalBody vm={vm} />,
})
// Remove notes if `''` is passed
await _call('vm.set', { id: resolveId(vm), notes: notes || null })
}
export const createKubernetesCluster = params => _call('xoa.recipe.createKubernetesCluster', params)
export const deleteTemplates = templates =>

View File

@@ -0,0 +1,46 @@
import _ from 'intl'
import addSubscriptions from 'add-subscriptions'
import BaseComponent from 'base-component'
import Icon from 'icon'
import React from 'react'
import { connectStore } from 'utils'
import { createGetObjectsOfType } from 'selectors'
import { subscribePlugins } from '../'
@addSubscriptions(() => ({
plugins: subscribePlugins,
}))
@connectStore(
{
pools: createGetObjectsOfType('pool'),
},
{ withRef: true }
)
export default class RollingPoolRebootModal extends BaseComponent {
render() {
const pool = this.props.pools[this.props.pool]
const loadBalancerPlugin =
this.props.plugins !== undefined && this.props.plugins.find(plugin => plugin.name === 'load-balancer')
return (
<div>
<p>{_('rollingPoolRebootMessage')}</p>
{pool.HA_enabled && (
<p>
<em className='text-warning'>
<Icon icon='alarm' /> {_('rollingPoolRebootHaWarning')}
</em>
</p>
)}
{loadBalancerPlugin !== undefined && loadBalancerPlugin.loaded && (
<p>
<em className='text-warning'>
<Icon icon='alarm' /> {_('rollingPoolRebootLoadBalancerWarning')}
</em>
</p>
)}
</div>
)
}
}

View File

@@ -695,6 +695,10 @@
@extend .fa;
@extend .fa-angle-double-down;
}
&-rolling-reboot {
@extend .fa;
@extend .fa-circle-o-notch;
}
}
&-vif {

View File

@@ -54,6 +54,7 @@ const TaskDuration = ({ task }) =>
const UNHEALTHY_VDI_CHAIN_ERROR = 'unhealthy VDI chain'
const UNHEALTHY_VDI_CHAIN_LINK = 'https://xen-orchestra.com/docs/backup_troubleshooting.html#vdi-chain-protection'
const VM_NOT_PRESENT = 'This VM is no longer present in XO'
const TaskError = ({ task }) => {
let message
@@ -188,7 +189,8 @@ TaskInfos.propTypes = {
const VmTask = ({ children, className, restartVmJob, task }) => (
<li className={className}>
<Vm id={task.data.id} name={task.data.name_label} link newTab /> <TaskStateInfos status={task.status} />{' '}
<Vm id={task.data.id} name={task.data.name_label ?? VM_NOT_PRESENT} link newTab />{' '}
<TaskStateInfos status={task.status} />{' '}
{restartVmJob !== undefined && hasTaskFailed(task) && (
<ButtonGroup>
<ActionButton

View File

@@ -26,6 +26,7 @@ import {
editPool,
installSupplementalPackOnAllHosts,
isSrWritable,
rollingPoolReboot,
setHostsMultipathing,
setPoolMaster,
setRemoteSyslogHost,
@@ -45,7 +46,7 @@ import { confirm } from '../../common/modal'
import { error } from '../../common/notification'
import { Host, Pool } from '../../common/render-xo-item'
import { isAdmin } from '../../common/selectors'
import { SOURCES, getXoaPlan } from '../../common/xoa-plans'
import { ENTERPRISE, SOURCES, getXoaPlan } from '../../common/xoa-plans'
const BindLicensesButton = decorate([
addSubscriptions({
@@ -266,12 +267,23 @@ export default class TabAdvanced extends Component {
const { enabled: hostsEnabledMultipathing, disabled: hostsDisabledMultipathing } = hostsByMultipathing
const { crashDumpSr } = pool
const crashDumpSrPredicate = this._getCrashDumpSrPredicate()
const isEnterprisePlan = getXoaPlan().value >= ENTERPRISE.value
return (
<div>
<Container>
{this._isNetboxPluginLoaded() && (
<Row>
<Col className='text-xs-right'>
<Row>
<Col className='text-xs-right'>
<TabButton
btnStyle='warning'
handler={rollingPoolReboot}
handlerParam={pool}
icon='pool-rolling-reboot'
labelId='rollingPoolReboot'
disabled={!isEnterprisePlan}
tooltip={!isEnterprisePlan ? _('onlyAvailableToEnterprise') : undefined}
/>
{this._isNetboxPluginLoaded() && (
<TabButton
btnStyle='primary'
handler={synchronizeNetbox}
@@ -279,9 +291,9 @@ export default class TabAdvanced extends Component {
icon='refresh'
labelId='syncNetbox'
/>
</Col>
</Row>
)}
)}
</Col>
</Row>
<Row>
<Col>
<h3>{_('xenSettingsLabel')}</h3>

View File

@@ -26,6 +26,7 @@ import {
generateAuditFingerprint,
getPlugin,
} from 'xo'
import RichText from 'rich-text'
const getIntegrityErrorRender = ({ nValid, error }) => (
<p className='text-danger'>
@@ -166,7 +167,7 @@ const displayRecord = record =>
<span>
<Icon icon='audit' /> {_('auditRecord')}
</span>,
<Copiable tagName='pre'>{JSON.stringify(record, null, 2)}</Copiable>
<RichText copiable message={JSON.stringify(record, null, 2)} />
)
const INDIVIDUAL_ACTIONS = [

View File

@@ -4,7 +4,6 @@ import { find, map } from 'lodash'
import _ from 'intl'
import BaseComponent from 'base-component'
import Copiable from 'copiable'
import NoObjects from 'no-objects'
import SortedTable from 'sorted-table'
import styles from './index.css'
@@ -14,6 +13,7 @@ import { createSelector } from 'selectors'
import { get } from '@xen-orchestra/defined'
import { reportBug } from 'report-bug-button'
import { deleteApiLog, deleteApiLogs, subscribeApiLogs, subscribeUsers } from 'xo'
import RichText from 'rich-text'
const formatMessage = data =>
`\`\`\`\n${data.method}\n${JSON.stringify(data.params, null, 2)}\n${JSON.stringify(data.error, null, 2).replace(
@@ -99,7 +99,7 @@ const ACTIONS = [
const INDIVIDUAL_ACTIONS = [
{
handler: log => alert(_('logError'), <Copiable tagName='pre'>{formatLog(log)}</Copiable>),
handler: log => alert(_('logError'), <RichText copiable message={formatLog(log)} />),
icon: 'preview',
label: _('logDisplayDetails'),
},

View File

@@ -1,15 +1,18 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Copiable from 'copiable'
import decorate from 'apply-decorators'
import defined, { get } from '@xen-orchestra/defined'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
import map from 'lodash/map'
import marked from 'marked'
import React from 'react'
import HomeTags from 'home-tags'
import renderXoItem, { VmTemplate } from 'render-xo-item'
import sanitizeHtml from 'sanitize-html'
import Tooltip from 'tooltip'
import { addTag, editVm, removeTag, subscribeUsers } from 'xo'
import { addTag, editVm, editVmNotes, removeTag, subscribeUsers } from 'xo'
import { BlockLink } from 'link'
import { FormattedRelative, FormattedDate } from 'react-intl'
import { Container, Row, Col } from 'grid'
@@ -31,6 +34,18 @@ const CREATED_VM_STYLES = {
whiteSpace: 'pre-line',
}
const NOTES_STYLE = {
maxWidth: '70%',
margin: 'auto',
border: 'dashed 1px #999',
padding: '1em',
borderRadius: '10px',
}
const SANITIZE_OPTIONS = {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
}
const GuestToolsDetection = ({ vm }) => {
if (vm.power_state !== 'Running' || vm.pvDriversDetected === undefined) {
return null
@@ -276,6 +291,20 @@ const GeneralTab = decorate([
</Col>
</Row>
)}
<Row className='mt-1'>
<div style={NOTES_STYLE}>
{vm.notes !== undefined && (
<p
dangerouslySetInnerHTML={{
__html: sanitizeHtml(marked(vm.notes), SANITIZE_OPTIONS),
}}
/>
)}
<ActionButton icon='edit' handler={editVmNotes} handlerParam={vm}>
{_('editVmNotes')}
</ActionButton>
</div>
</Row>
</Container>
)
},

2373
yarn.lock

File diff suppressed because it is too large Load Diff