Compare commits
12 Commits
loadBalanc
...
fix-emptyB
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
838576c8be | ||
|
|
e8bc723f98 | ||
|
|
8e65ef7dbc | ||
|
|
0c0251082d | ||
|
|
c250cd9b89 | ||
|
|
d6abdb246b | ||
|
|
5769da3ebc | ||
|
|
4f383635ef | ||
|
|
8a7abc2e54 | ||
|
|
af1650bd14 | ||
|
|
c6fdef33c4 | ||
|
|
5f73f09f59 |
28
@vates/fuse-vhd/.USAGE.md
Normal file
28
@vates/fuse-vhd/.USAGE.md
Normal 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
59
@vates/fuse-vhd/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/fuse-vhd
|
||||
|
||||
[](https://npmjs.org/package/@vates/fuse-vhd)  [](https://bundlephobia.com/result?p=@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
26
@vates/fuse-vhd/cli.mjs
Executable 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))
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"preferGlobal": false,
|
||||
"main": "dist/",
|
||||
"engines": {
|
||||
"node": ">=12.9"
|
||||
"node": ">=7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/cron": "^1.0.6",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// ===================================================================
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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]
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
95
packages/xo-server/src/xo-mixins/_xoJsonSchema.mjs
Normal file
95
packages/xo-server/src/xo-mixins/_xoJsonSchema.mjs
Normal 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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,3 +7,9 @@
|
||||
.container:hover .button {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
:global(.modal-body) .container .button {
|
||||
position: relative;
|
||||
margin-left: -4ex;
|
||||
left: 3.5em;
|
||||
}
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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}}?',
|
||||
|
||||
79
packages/xo-web/src/common/rich-text.js
Normal file
79
packages/xo-web/src/common/rich-text.js
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
36
packages/xo-web/src/common/xo/edit-vm-notes-modal/index.js
Normal file
36
packages/xo-web/src/common/xo/edit-vm-notes-modal/index.js
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -695,6 +695,10 @@
|
||||
@extend .fa;
|
||||
@extend .fa-angle-double-down;
|
||||
}
|
||||
&-rolling-reboot {
|
||||
@extend .fa;
|
||||
@extend .fa-circle-o-notch;
|
||||
}
|
||||
}
|
||||
|
||||
&-vif {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user