Compare commits

...

91 Commits

Author SHA1 Message Date
Pizzosaure
d70da2a960 changelog entry added 2023-11-03 15:14:23 +01:00
Pizzosaure
637eb1d2d7 feat(xo-web/forgetSR): improve the modal window message 2023-11-03 15:02:07 +01:00
Pizzosaure
86b86c5c99 merge conflict 2023-11-02 13:56:16 +01:00
Pizzosaure
0b8525febe fix(xo-server/resourceSets): adding VM in resource set displayed error even when succeeded 2023-11-02 13:53:12 +01:00
Pierre Donias
a3ea70c61c fix(xo-server-netbox): fix site property null/undefined cases (#7145)
Introduced by 1d7559ded2
2023-10-31 16:16:38 +01:00
Mathieu
ae0f3b4fe0 feat: release 5.88.0 (#7143) 2023-10-31 14:58:50 +01:00
Mathieu
2552ef37d2 feat: technical release (#7141) 2023-10-31 10:09:35 +01:00
Pierre Donias
9803e8c6cb feat(xo-web/patches): warning about updating pool master first (#7140) 2023-10-31 09:51:17 +01:00
Florent BEAUCHAMP
3410cbc3b9 fix(backups): use VDI virtual_size instead of physical_size
`physical_size` appears to be broken
2023-10-30 15:55:30 +01:00
Florent BEAUCHAMP
93fce0d4bf fix(backups): pass type to Xapi#getRecord
Fixes #7131

Introduced by 37b211376
2023-10-30 15:55:30 +01:00
MlssFrncJrg
dbdc5f3e3b feat(xo-web/New network): don't show PIFs that belong to a bond (#7136) 2023-10-30 15:47:38 +01:00
Julien Fontanet
581b42fa9d feat(xo-cli): only create a single token per instance (and user) 2023-10-30 15:47:17 +01:00
Julien Fontanet
e07e2d3ccd feat(xo-server/token): client info support 2023-10-30 15:47:17 +01:00
Mathieu
ad928ec23d fix(xo-web/licenses/XOSTOR): various fixes on XOSTOR licenses (#7137)
Introduced by #6983
2023-10-30 14:55:11 +01:00
Pierre Donias
1d7559ded2 fix(xo-server-netbox/VM): explicitly assign site (#7124)
See Zammad#17766
See https://xcp-ng.org/forum/topic/7887
2023-10-30 11:32:12 +01:00
Mathieu
9099b58557 feat: technical release (#7132) 2023-10-27 16:13:04 +02:00
Julien Fontanet
9e70397240 fix(xo-server/redis): fix indexes handling
Introduced by 225a67ae3
2023-10-27 11:27:25 +02:00
Thierry Goettelmann
5f69b0e9a0 feat(lite/console): new console toolbar (#7088) 2023-10-27 10:27:51 +02:00
Julien Fontanet
2a9bff1607 chore(xo-server/importConfig): don't use deptree 2023-10-27 10:14:02 +02:00
Pierre Donias
9e621d7de8 feat(lite/header): replace logo with "XO LITE" (#7118) 2023-10-27 09:16:28 +02:00
Mathieu
3e5c73528d feat(xo-server,xo-web/XOSTOR): XOSTOR implementation (#6983)
See https://xcp-ng.org/forum/topic/5361
2023-10-26 16:58:59 +02:00
Pierre Donias
397b5cd56d fix(xo-server/snapshot): allow self user that is member of a group to snapshot (#7129)
Introduced by a88798cc22
See Zammad#18478
2023-10-26 16:08:43 +02:00
Julien Fontanet
55cb6042e8 chore(yarn.lock): update dev deps 2023-10-26 11:00:14 +02:00
Pierre Donias
339d920b78 feat(xo-web/proxy): ability to open support tunnel on XO Proxy (#7127)
Requires #7126
2023-10-25 17:26:06 +02:00
Julien Fontanet
f14f716f3d feat(xo-server/api): proxy.openSupportTunnel (#7126)
The goal is to provide an easier way for the support team to open a tunnel on a proxy appliance.

This is the server side of this feature.
2023-10-25 17:12:17 +02:00
Julien Fontanet
fb83d1fc98 feat(xo-server/api): ignorable parameters (#7125) 2023-10-25 15:49:41 +02:00
Julien Fontanet
62208e7847 fix(xo-server-transport-xmpp): fix loading (#7082)
Fixes https://xcp-ng.org/forum/post/66402

Introduced by d6fc86b6b
2023-10-25 14:36:40 +02:00
Julien Fontanet
df91772f5c chore(xo-server/server): use builtin (un)serialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
cf8a9d40be chore(xo-server/remote): use builtin (un)serialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
93d1c6c3fc chore(xo-server/plugin-metadata): use builtin (un)serialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
f1fa811e5c chore(xo-server/user): use builtin (un)serialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
5a9812c492 chore(xo-server/group): use builtin (un)serialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
b53d613a64 chore(xo-server/token): use builtin unserialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
225a67ae3b chore(xo-server/redis): proper (un)serialization support 2023-10-25 11:48:53 +02:00
Mathieu
c7eb7db463 feat(xo-web/about): display if XO from source is up to date (#7091)
Fixes #5934
2023-10-24 17:14:01 +02:00
Pierre Donias
edfa729672 chore(lite/assets): remove darkreader properties in SVG files (#7121) 2023-10-24 16:40:37 +02:00
Mathieu
77d9798319 fix(xo-web/vtpm): fix various an error has occured (#7122)
Introduced by 8834af65f7
Introduced by 1a1dd0531d

Fix `an error has occurred` in the VM advanced tab and on the VM creation form
if the user does not have pool permission.
2023-10-24 16:26:36 +02:00
Pierre Donias
680f1e2f07 chore(lite): serve Poppins font internally (#7117) 2023-10-24 15:19:54 +02:00
Julien Fontanet
7c009b0fc0 feat(xo-server): support reading JSON records in Redis
This allows forward compatibility with future versions which will use JSON records in the future.
2023-10-23 15:13:28 +02:00
Pierre Donias
eb7de4f2dd feat(xo-web/self): show # of VMs that belong to each Resource Set (#7114)
See Zammad#17568
2023-10-23 15:03:30 +02:00
Olivier Lambert
2378399981 docs: update project's README (#7116) 2023-10-23 14:25:03 +02:00
Florent BEAUCHAMP
37b2113763 feat(fs/s3): compute sensible chunk size for uploads 2023-10-23 10:23:50 +02:00
Florent BEAUCHAMP
5048485a85 feat(fs/s3): object lock mode need content md5
and the middleware consume addiitionnal memory
2023-10-23 10:23:50 +02:00
Florent BEAUCHAMP
9e667533e9 fix(fs/s3): throw error if upload >50GB 2023-10-23 10:23:50 +02:00
MlssFrncJrg
1fac7922b4 feat(xo-web/dashboard/health): VDIs to coalesce warning contains the number (#7111)
Fixes Zammad#17577
2023-10-20 15:53:24 +02:00
Julien Fontanet
1a0e5eb6fc chore: format with Prettier 2023-10-20 15:52:10 +02:00
Pierre Donias
321e322492 feat(xo-server/clearHost): pass optional batch size arg (#7107)
Fixes #7105
See https://github.com/xapi-project/xen-api/issues/5202
See https://github.com/xapi-project/xen-api/pull/5203

`host.evacuate`: try passing optional batch size argument.
If not supported: remove it and try again.
2023-10-19 17:03:14 +02:00
Mathieu
8834af65f7 feat(xo-server/xo-web/VM/new): VTPM creation (#7077)
See #7066
See #6802
See #7085
2023-10-19 16:48:56 +02:00
Mathieu
1a1dd0531d feat(xo-web/VM/advanced): VTPM management (#7085)
See #7066
See #6802
See #7074
2023-10-19 15:46:03 +02:00
Pierre Donias
8752487280 docs(installation): add nfs-common dependency for Debian/Ubuntu (#7108) 2023-10-18 22:50:29 +02:00
Pierre Donias
4b12a6d31d fix(xo-server-usage-report): handle null and nested stats (#7092)
Introduced by 083483645e

Fixes Zammad#18120
Fixes Zammad#18266

- Always assume that data can be `null`
- Handle edge cases where all values are `null`
- Properly handle nested RRD collections: collections have different depths (`memory`: 1, `cpus[0]`: 2, `pifs.rx[0]`: 3, ...). This PR replaces `getLastDays` which wouldn't handle those depths properly, with `getDeepLastValues` which is run on the whole stat object and doesn't assume the depth of the collections. It finds any Array at any depth and slices it to only keep the last N values.
2023-10-18 22:50:08 +02:00
Julien Fontanet
2924f82754 fix(xo-web): don't sign out on connection error (#7103)
May fix zammad#17717

Introduced by 005ab47d9
2023-10-18 18:07:16 +02:00
Pierre Donias
9b236a6191 fix(netbox/test): test custom fields first (#7104)
More atomic and it makes more sense for users to check that the Netbox
configuration is correct before doing any write operations
2023-10-18 11:56:10 +02:00
Julien Fontanet
a3b8553cec fix(xo-server,xo-web): fix total number of VDIs to coalesce (#7098)
Fixes #7016

Summing all chains does take not common chains into account, the total must be computed on the server side.
2023-10-18 11:52:43 +02:00
Pierre Donias
00a1778a6d feat(lite): set color-scheme CSS property to "dark" in dark mode (#7101) 2023-10-17 16:50:13 +02:00
MlssFrncJrg
3b6bc629bc fix(xo-web/home): fix misaligned descriptions (#7090) 2023-10-16 15:53:35 +02:00
Pierre Donias
04dfd9a02c fix(xo-server-usage-report): use @xen-orchestra/log to log errors (#7096)
Fixes Zammad#14579
Fixes Zammad#18183

Better handles error objects with a circular structure and avoids "Converting
circular structure to JSON" error on stringify
2023-10-16 10:07:57 +02:00
Pierre Donias
fb52868074 fix(xo-server/patching): always check that XS credentials are configured on XS (#7093)
Introduced by a30d962b1d
2023-10-13 16:49:04 +02:00
Pierre Donias
77d53d2abf fix(xo-server/patching): always pass xsCredentials to installPatches on XS (#7089)
Fixes Zammad#18284

Introduced by a30d962b1d
2023-10-13 11:45:17 +02:00
Julien Fontanet
6afb87def1 feat(xo-server/vm.set): support xenStoreData
Fixes #7055
2023-10-13 11:26:48 +02:00
Mathieu
8bfe293414 feat(lite/VM): add copy, snapshot single action (#7087) 2023-10-12 11:09:11 +02:00
Mathieu
2e634a9d1c feat(xapi/VTPM): ability to create, destroy VTPM (#7074) 2023-10-12 09:19:38 +02:00
Pierre Donias
bea771ca90 fix(xo-server/RPU): do not migrate VM back if already on host (#7071)
See https://xcp-ng.org/forum/topic/7802
2023-10-11 16:16:44 +02:00
Pierre Donias
99e3622f31 feat(xo-web/SelectPif): show network name (#7081)
See Zammad#17381
2023-10-10 15:59:24 +02:00
Pizzosaure
a16522241e docs(netbox): remove extra backtick (#7083)
Introduced by 3b3f927e4b
2023-10-10 14:14:15 +02:00
Julien Fontanet
b86cb12649 chore(yarn.lock): update dev deps 2023-10-09 17:06:54 +02:00
Julien Fontanet
2af74008b2 feat(xo-server-backup-reports): errors are logged as XO tasks 2023-10-09 09:35:24 +02:00
Julien Fontanet
2e689592f1 feat(xo-server-backup-reports): error when transports not enabled 2023-10-09 09:35:24 +02:00
Julien Fontanet
3f8436b58b fix(xo-server/authenticateUser): use clearLogOnSuccess
This fixes success logs not deleted due to race conditions.
2023-10-09 09:35:24 +02:00
Julien Fontanet
e3dd59d684 feat(mixins/Tasks#create): clearLogOnSuccess option 2023-10-09 09:35:24 +02:00
mathieuRA
549d9b70a9 feat(xo-web/host): allow to force smartReboot 2023-10-06 16:52:26 +02:00
mathieuRA
3bf6aae103 feat(xapi/host_smartReboot): ability to bypass blocked operations 2023-10-06 16:52:26 +02:00
Julien Fontanet
afb110c473 fix(fs/rmtree): fix huge memory usage (#7073)
Fixes zammad#15258

This adds a sane concurrency limit of 2 per depth level.

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.fr>
2023-10-06 09:52:11 +02:00
Pierre Donias
8727c3cf96 docs(patches): update URLs that need to be accessible from XOA (#7075) 2023-10-05 09:45:50 +02:00
Julien Fontanet
b13302ddeb fix(xen-api/cli): dont run default export when imported by ESM
Fix a bug in `@xen-orchestra/xapi` introduced by c3e0308ad

`module.parent` is `null` when the module is the entry point but `undefined` when imported via ESM.
2023-10-04 10:06:17 +02:00
Julien Fontanet
e89ed06314 docs(installation): Node 18 required
XO is not compatible with Node > 18 for the moment, as Node 20 will
likely graduate to LTS soon, the docs must explicitly recommend 18.
2023-10-04 09:25:37 +02:00
Malcolm Scott
e3f57998f7 fix(signin): try to preserve current page across reauthentication (#7013)
If an authentication session expires or is lost for whatever reason, XO redirects to `/signin`.  This redirect generally preserves the URL fragment (hash) which contains the page selected prior to reauthentication, i.e. if the user had been in settings/servers just beforehand, they end up at `/signin#settings/servers`.  However, currently when they log back in they end up on the home page; the page they were on is forgotten.

This commit tries to send the user back to the page they were viewing before reauthentication, by preserving the URL fragment in the login form action / by appending it to the links to authentication plugins.  (Not all authentication plugins will necessarily preserve it internally, but we can optimistically try it and see; at worst the old behaviour will remain.)
2023-10-03 12:39:57 +02:00
Julien Fontanet
8cdb5ee31b chore: update dev deps 2023-10-03 11:24:51 +02:00
Pierre Donias
5b734db656 feat(lite): 0.1.4 (#7068) 2023-10-03 10:26:05 +02:00
rbarhtaoui
e853f9d04f feat(lite): display loading icon and error message when data is not fetched (#6775) 2023-10-03 10:03:44 +02:00
Mathieu
2a5e09719e feat(lite/login): add remember me checkbox (#7030) 2023-10-03 10:01:07 +02:00
Pierre Donias
3c0477e0da feat: release 5.87.0 (#7064) 2023-09-29 11:35:23 +02:00
Pierre Donias
060d1c5297 feat: technical release (#7063) 2023-09-29 10:01:45 +02:00
Julien Fontanet
55dd7bfb9c feat(backups): don't snapshot migrating VMs
Related to zammad#16108
2023-09-28 17:42:43 +02:00
Julien Fontanet
b00cf13029 feat(backups): block snapshot migration during backup
Related to zammad#16108
2023-09-28 17:42:43 +02:00
Julien Fontanet
73755e4ccf feat(xo-server/authenticateUser): log failed attempts
Related to zammad#16318
2023-09-28 17:38:57 +02:00
Julien Fontanet
a1bd96da6a feat(mixins/Tasks#create): allow any properties 2023-09-28 17:38:57 +02:00
mathieuRA
0e934c1413 feat(xo-web/host/advanced): display system disks health 2023-09-28 17:14:09 +02:00
Florent BEAUCHAMP
eb69234a8e feat(xo-server/host): implement smartctl api call 2023-09-28 17:14:09 +02:00
mathieuRA
7659d9c0be fix(xo-web/host/advanced): catch error for ACLs users on hyper threading plugin
it broke the componentDidMount methode and didn't update the state correctly
2023-09-28 17:14:09 +02:00
Florent BEAUCHAMP
2ba81d55f8 fix(vhd-lib/test): collision during tests (#7062)
multiple tests use the same temporary files
2023-09-28 16:49:00 +02:00
143 changed files with 4895 additions and 2447 deletions

View File

@@ -22,7 +22,7 @@
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.6.0"
"vhd-lib": "^4.6.1"
},
"scripts": {
"postversion": "npm publish --access public"

View File

@@ -7,8 +7,8 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.42.1",
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/backups": "^0.43.2",
"@xen-orchestra/fs": "^4.1.1",
"filenamify": "^6.0.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",
@@ -27,7 +27,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "1.0.12",
"version": "1.0.13",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -681,11 +681,13 @@ export class RemoteAdapter {
}
}
async outputStream(path, input, { checksum = true, validator = noop } = {}) {
async outputStream(path, input, { checksum = true, maxStreamLength, streamLength, validator = noop } = {}) {
const container = watchStreamSize(input)
await this._handler.outputStream(path, input, {
checksum,
dirMode: this._dirMode,
maxStreamLength,
streamLength,
async validator() {
await input.task
return validator.apply(this, arguments)

View File

@@ -29,6 +29,8 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
writer =>
writer.run({
stream: forkStreamUnpipe(stream),
// stream will be forked and transformed, it's not safe to attach additionnal properties to it
streamLength: stream.length,
timestamp: metadata.timestamp,
vm: metadata.vm,
vmSnapshot: metadata.vmSnapshot,

View File

@@ -35,13 +35,25 @@ export const FullXapi = class FullXapiVmBackupRunner extends AbstractXapi {
useSnapshot: false,
})
)
const vdis = await exportedVm.$getDisks()
let maxStreamLength = 1024 * 1024 // Ovf file and tar headers are a few KB, let's stay safe
for (const vdiRef of vdis) {
const vdi = await this._xapi.getRecord('VDI', vdiRef)
// the size a of fully allocated vdi will be virtual_size exaclty, it's a gross over evaluation
// of the real stream size in general, since a disk is never completly full
// vdi.physical_size seems to underevaluate a lot the real disk usage of a VDI, as of 2023-10-30
maxStreamLength += vdi.virtual_size
}
const sizeContainer = watchStreamSize(stream)
const timestamp = Date.now()
await this._callWriters(
writer =>
writer.run({
maxStreamLength,
sizeContainer,
stream: forkStreamUnpipe(stream),
timestamp,

View File

@@ -31,6 +31,11 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
throw new Error('cannot backup a VM created by this very job')
}
const currentOperations = Object.values(vm.current_operations)
if (currentOperations.some(_ => _ === 'migrate_send' || _ === 'pool_migrate')) {
throw new Error('cannot backup a VM currently being migrated')
}
this.config = config
this.job = job
this.remoteAdapters = remoteAdapters
@@ -256,7 +261,15 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
}
if (this._writers.size !== 0) {
await this._copy()
const { pool_migrate = null, migrate_send = null } = this._exportedVm.blocked_operations
const reason = 'VM migration is blocked during backup'
await this._exportedVm.update_blocked_operations({ pool_migrate: reason, migrate_send: reason })
try {
await this._copy()
} finally {
await this._exportedVm.update_blocked_operations({ pool_migrate, migrate_send })
}
}
} finally {
if (startAfter) {

View File

@@ -24,7 +24,7 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
)
}
async _run({ timestamp, sizeContainer, stream, vm, vmSnapshot }) {
async _run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
const settings = this._settings
const job = this._job
const scheduleId = this._scheduleId
@@ -65,6 +65,8 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
await Task.run({ name: 'transfer' }, async () => {
await adapter.outputStream(dataFilename, stream, {
maxStreamLength,
streamLength,
validator: tmpPath => adapter.isValidXva(tmpPath),
})
return { size: sizeContainer.size }

View File

@@ -1,9 +1,9 @@
import { AbstractWriter } from './_AbstractWriter.mjs'
export class AbstractFullWriter extends AbstractWriter {
async run({ timestamp, sizeContainer, stream, vm, vmSnapshot }) {
async run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
try {
return await this._run({ timestamp, sizeContainer, stream, vm, vmSnapshot })
return await this._run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot })
} finally {
// ensure stream is properly closed
stream.destroy()

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.42.1",
"version": "0.43.2",
"engines": {
"node": ">=14.18"
},
@@ -28,7 +28,7 @@
"@vates/nbd-client": "^2.0.0",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/fs": "^4.1.1",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/template": "^0.1.0",
"app-conf": "^2.3.0",
@@ -44,7 +44,7 @@
"proper-lockfile": "^4.1.2",
"tar": "^6.1.15",
"uuid": "^9.0.0",
"vhd-lib": "^4.6.0",
"vhd-lib": "^4.6.1",
"xen-api": "^1.3.6",
"yazl": "^2.5.1"
},
@@ -56,7 +56,7 @@
"tmp": "^0.2.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^3.1.0"
"@xen-orchestra/xapi": "^3.3.0"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "4.1.0",
"version": "4.1.1",
"license": "AGPL-3.0-or-later",
"description": "The File System for Xen Orchestra backups.",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",

View File

@@ -189,7 +189,7 @@ export default class RemoteHandlerAbstract {
* @param {number} [options.dirMode]
* @param {(this: RemoteHandlerAbstract, path: string) => Promise<undefined>} [options.validator] Function that will be called before the data is commited to the remote, if it fails, file should not exist
*/
async outputStream(path, input, { checksum = true, dirMode, validator } = {}) {
async outputStream(path, input, { checksum = true, dirMode, maxStreamLength, streamLength, validator } = {}) {
path = normalizePath(path)
let checksumStream
@@ -201,6 +201,8 @@ export default class RemoteHandlerAbstract {
}
await this._outputStream(path, input, {
dirMode,
maxStreamLength,
streamLength,
validator,
})
if (checksum) {
@@ -624,14 +626,18 @@ export default class RemoteHandlerAbstract {
const files = await this._list(dir)
await asyncEach(files, file =>
this._unlink(`${dir}/${file}`).catch(error => {
// Unlink dir behavior is not consistent across platforms
// https://github.com/nodejs/node-v0.x-archive/issues/5791
if (error.code === 'EISDIR' || error.code === 'EPERM') {
return this._rmtree(`${dir}/${file}`)
}
throw error
})
this._unlink(`${dir}/${file}`).catch(
error => {
// Unlink dir behavior is not consistent across platforms
// https://github.com/nodejs/node-v0.x-archive/issues/5791
if (error.code === 'EISDIR' || error.code === 'EPERM') {
return this._rmtree(`${dir}/${file}`)
}
throw error
},
// real unlink concurrency will be 2**max directory depth
{ concurrency: 2 }
)
)
return this._rmtree(dir)
}

View File

@@ -5,6 +5,7 @@ import {
CreateMultipartUploadCommand,
DeleteObjectCommand,
GetObjectCommand,
GetObjectLockConfigurationCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
@@ -17,7 +18,7 @@ import { getApplyMd5BodyChecksumPlugin } from '@aws-sdk/middleware-apply-body-ch
import { Agent as HttpAgent } from 'http'
import { Agent as HttpsAgent } from 'https'
import { createLogger } from '@xen-orchestra/log'
import { PassThrough, pipeline } from 'stream'
import { PassThrough, Transform, pipeline } from 'stream'
import { parse } from 'xo-remote-parser'
import copyStreamToBuffer from './_copyStreamToBuffer.js'
import guessAwsRegion from './_guessAwsRegion.js'
@@ -30,6 +31,8 @@ import { pRetry } from 'promise-toolbox'
// limits: https://docs.aws.amazon.com/AmazonS3/latest/dev/qfacts.html
const MAX_PART_SIZE = 1024 * 1024 * 1024 * 5 // 5GB
const MAX_PART_NUMBER = 10000
const MIN_PART_SIZE = 5 * 1024 * 1024
const { warn } = createLogger('xo:fs:s3')
export default class S3Handler extends RemoteHandlerAbstract {
@@ -71,9 +74,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
}),
})
// Workaround for https://github.com/aws/aws-sdk-js-v3/issues/2673
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
const parts = split(path)
this.#bucket = parts.shift()
this.#dir = join(...parts)
@@ -223,11 +223,35 @@ export default class S3Handler extends RemoteHandlerAbstract {
}
}
async _outputStream(path, input, { validator }) {
async _outputStream(path, input, { streamLength, maxStreamLength = streamLength, validator }) {
// S3 storage is limited to 10K part, each part is limited to 5GB. And the total upload must be smaller than 5TB
// a bigger partSize increase the memory consumption of aws/lib-storage exponentially
let partSize
if (maxStreamLength === undefined) {
warn(`Writing ${path} to a S3 remote without a max size set will cut it to 50GB`, { path })
partSize = MIN_PART_SIZE // min size for S3
} else {
partSize = Math.min(Math.max(Math.ceil(maxStreamLength / MAX_PART_NUMBER), MIN_PART_SIZE), MAX_PART_SIZE)
}
// ensure we don't try to upload a stream to big for this partSize
let readCounter = 0
const MAX_SIZE = MAX_PART_NUMBER * partSize
const streamCutter = new Transform({
transform(chunk, encoding, callback) {
readCounter += chunk.length
if (readCounter > MAX_SIZE) {
callback(new Error(`read ${readCounter} bytes, maximum size allowed is ${MAX_SIZE} `))
} else {
callback(null, chunk)
}
},
})
// Workaround for "ReferenceError: ReadableStream is not defined"
// https://github.com/aws/aws-sdk-js-v3/issues/2522
const Body = new PassThrough()
pipeline(input, Body, () => {})
pipeline(input, streamCutter, Body, () => {})
const upload = new Upload({
client: this.#s3,
@@ -235,6 +259,8 @@ export default class S3Handler extends RemoteHandlerAbstract {
...this.#createParams(path),
Body,
},
partSize,
leavePartsOnError: false,
})
await upload.done()
@@ -418,6 +444,24 @@ export default class S3Handler extends RemoteHandlerAbstract {
async _closeFile(fd) {}
async _sync() {
await super._sync()
try {
// if Object Lock is enabled, each upload must come with a contentMD5 header
// the computation of this md5 is memory-intensive, especially when uploading a stream
const res = await this.#s3.send(new GetObjectLockConfigurationCommand({ Bucket: this.#bucket }))
if (res.ObjectLockConfiguration?.ObjectLockEnabled === 'Enabled') {
// Workaround for https://github.com/aws/aws-sdk-js-v3/issues/2673
// will automatically add the contentMD5 header to any upload to S3
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
}
} catch (error) {
if (error.Code !== 'ObjectLockConfigurationNotFoundError') {
throw error
}
}
}
useVhdDirectory() {
return true
}

View File

@@ -2,9 +2,16 @@
## **next**
- Ability to snapshot/copy a VM from its view (PR [#7087](https://github.com/vatesfr/xen-orchestra/pull/7087))
- [Header] Replace logo with "XO LITE" (PR [#7118](https://github.com/vatesfr/xen-orchestra/pull/7118))
- New VM console toolbar + Ability to send Ctrl+Alt+Del (PR [#7088](https://github.com/vatesfr/xen-orchestra/pull/7088))
## **0.1.4** (2023-10-03)
- Ability to migrate selected VMs to another host (PR [#7040](https://github.com/vatesfr/xen-orchestra/pull/7040))
- Ability to snapshot selected VMs (PR [#7021](https://github.com/vatesfr/xen-orchestra/pull/7021))
- Add Patches to Pool Dashboard (PR [#6709](https://github.com/vatesfr/xen-orchestra/pull/6709))
- Add remember me checkbox on the login page (PR [#7030](https://github.com/vatesfr/xen-orchestra/pull/7030))
## **0.1.3** (2023-09-01)

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/lite",
"version": "0.1.3",
"version": "0.1.4",
"scripts": {
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
"build": "run-p type-check build-only",
@@ -11,6 +11,7 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@fontsource/poppins": "^5.0.8",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.2.0",
"@fortawesome/free-solid-svg-icons": "^6.2.0",

View File

@@ -1,7 +1,11 @@
@import "reset.css";
@import "theme.css";
/* TODO Serve fonts locally */
@import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;0,900;1,400;1,500;1,600;1,700;1,900&display=swap");
@import "@fontsource/poppins/400.css";
@import "@fontsource/poppins/500.css";
@import "@fontsource/poppins/600.css";
@import "@fontsource/poppins/700.css";
@import "@fontsource/poppins/900.css";
@import "@fontsource/poppins/400-italic.css";
body {
min-height: 100vh;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,4 +1,6 @@
:root {
--color-logo: #282467;
--color-blue-scale-000: #000000;
--color-blue-scale-100: #1a1b38;
--color-blue-scale-200: #595a6f;
@@ -59,6 +61,10 @@
}
:root.dark {
color-scheme: dark;
--color-logo: #e5e5e7;
--color-blue-scale-000: #ffffff;
--color-blue-scale-100: #e5e5e7;
--color-blue-scale-200: #9899a5;

View File

@@ -7,7 +7,8 @@
class="toggle-navigation"
/>
<RouterLink :to="{ name: 'home' }">
<img alt="XO Lite" src="../assets/logo.svg" />
<img v-if="isMobile" alt="XO Lite" src="../assets/logo.svg" />
<TextLogo v-else />
</RouterLink>
<slot />
<div class="right">
@@ -18,6 +19,7 @@
<script lang="ts" setup>
import AccountButton from "@/components/AccountButton.vue";
import TextLogo from "@/components/TextLogo.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { useNavigationStore } from "@/stores/navigation.store";
import { useUiStore } from "@/stores/ui.store";
@@ -44,6 +46,10 @@ const { trigger: navigationTrigger } = storeToRefs(navigationStore);
img {
width: 4rem;
}
.text-logo {
margin: 1rem;
}
}
.right {

View File

@@ -16,6 +16,10 @@
required
/>
</FormInputWrapper>
<label class="remember-me-label">
<FormCheckbox v-model="rememberMe" />
<p>{{ $t("keep-me-logged") }}</p>
</label>
<UiButton type="submit" :busy="isConnecting">
{{ $t("login") }}
</UiButton>
@@ -28,6 +32,9 @@ import { usePageTitleStore } from "@/stores/page-title.store";
import { storeToRefs } from "pinia";
import { onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useLocalStorage } from "@vueuse/core";
import FormCheckbox from "@/components/form/FormCheckbox.vue";
import FormInput from "@/components/form/FormInput.vue";
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
import UiButton from "@/components/ui/UiButton.vue";
@@ -42,12 +49,16 @@ const password = ref("");
const error = ref<string>();
const passwordRef = ref<InstanceType<typeof FormInput>>();
const isInvalidPassword = ref(false);
const rememberMe = useLocalStorage("rememberMe", false);
const focusPasswordInput = () => passwordRef.value?.focus();
onMounted(() => {
xenApiStore.reconnect();
focusPasswordInput();
if (rememberMe.value) {
xenApiStore.reconnect();
} else {
focusPasswordInput();
}
});
watch(password, () => {
@@ -72,6 +83,19 @@ async function handleSubmit() {
</script>
<style lang="postcss" scoped>
.remember-me-label {
cursor: pointer;
width: fit-content;
& .form-checkbox {
margin: 1rem 1rem 1rem 0;
vertical-align: middle;
}
& p {
display: inline;
vertical-align: middle;
}
}
.form-container {
display: flex;
align-items: center;
@@ -87,7 +111,6 @@ form {
font-size: 2rem;
min-width: 30em;
max-width: 100%;
align-items: center;
flex-direction: column;
justify-content: center;
margin: 0 auto;
@@ -104,7 +127,7 @@ h1 {
img {
width: 40rem;
margin-bottom: 5rem;
margin: auto auto 5rem auto;
}
input {
@@ -118,6 +141,6 @@ input {
}
button {
margin-top: 2rem;
margin: 2rem auto;
}
</style>

View File

@@ -105,6 +105,10 @@ watchEffect(() => {
onBeforeUnmount(() => {
clearVncClient();
});
defineExpose({
sendCtrlAltDel: () => vncClient?.sendCtrlAltDel(),
});
</script>
<style lang="postcss" scoped>

View File

@@ -0,0 +1,37 @@
<template>
<svg
class="text-logo"
viewBox="300.85 622.73 318.32 63.27"
xmlns="http://www.w3.org/2000/svg"
width="100"
height="22"
>
<g>
<polygon
points="355.94 684.92 341.54 684.92 327.84 664.14 315.68 684.92 301.81 684.92 317.59 659.25 338.96 659.25 355.94 684.92"
/>
<path
d="M406.2,627.17c4.62,2.64,8.27,6.33,10.94,11.07,2.67,4.74,4.01,10.1,4.01,16.07s-1.34,11.35-4.01,16.12c-2.67,4.77-6.32,8.48-10.94,11.12-4.63,2.64-9.78,3.97-15.47,3.97s-10.85-1.32-15.47-3.97c-4.63-2.64-8.27-6.35-10.95-11.12-2.67-4.77-4.01-10.14-4.01-16.12s1.34-11.33,4.01-16.07c2.67-4.74,6.32-8.43,10.95-11.07,4.62-2.64,9.78-3.97,15.47-3.97s10.84,1.32,15.47,3.97Zm-24.86,9.65c-2.7,1.61-4.81,3.92-6.33,6.94-1.52,3.02-2.28,6.54-2.28,10.56s.76,7.54,2.28,10.56c1.52,3.02,3.63,5.33,6.33,6.94,2.7,1.61,5.83,2.41,9.39,2.41s6.69-.8,9.39-2.41c2.7-1.61,4.81-3.92,6.33-6.94,1.52-3.02,2.28-6.53,2.28-10.56s-.76-7.54-2.28-10.56-3.63-5.33-6.33-6.94c-2.7-1.61-5.83-2.41-9.39-2.41s-6.69,.8-9.39,2.41Z"
/>
<polygon
points="354.99 624.06 339.53 649.22 317.49 649.22 300.86 624.06 315.26 624.06 328.96 644.84 341.12 624.06 354.99 624.06"
/>
<g>
<path d="M476.32,675.94h20.81v10.04h-33.47v-63.14h12.66v53.1Z" />
<path d="M517.84,622.84v63.14h-12.66v-63.14h12.66Z" />
<path
d="M573.29,622.84v10.22h-16.82v52.92h-12.66v-52.92h-16.83v-10.22h46.31Z"
/>
<path
d="M595.18,633.06v15.83h21.26v10.04h-21.26v16.73h23.97v10.31h-36.64v-63.23h36.64v10.31h-23.97Z"
/>
</g>
</g>
</svg>
</template>
<style lang="postcss" scoped>
.text-logo {
fill: var(--color-logo);
}
</style>

View File

@@ -2,12 +2,7 @@
```vue
<template>
<LinearChart
title="Chart title"
subtitle="Chart subtitle"
:data="data"
:value-formatter="customValueFormatter"
/>
<LinearChart :data="data" :value-formatter="customValueFormatter" />
</template>
<script lang="ts" setup>

View File

@@ -1,12 +1,8 @@
<template>
<UiCard class="linear-chart">
<VueCharts :option="option" autoresize class="chart" />
<slot name="summary" />
</UiCard>
<VueCharts :option="option" autoresize class="chart" />
</template>
<script lang="ts" setup>
import UiCard from "@/components/ui/UiCard.vue";
import type { LinearChartData, ValueFormatter } from "@/types/chart";
import { IK_CHART_VALUE_FORMATTER } from "@/types/injection-keys";
import { utcFormat } from "d3-time-format";
@@ -15,7 +11,6 @@ import { LineChart } from "echarts/charts";
import {
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
} from "echarts/components";
import { use } from "echarts/core";
@@ -26,8 +21,6 @@ import VueCharts from "vue-echarts";
const Y_AXIS_MAX_VALUE = 200;
const props = defineProps<{
title?: string;
subtitle?: string;
data: LinearChartData;
valueFormatter?: ValueFormatter;
maxValue?: number;
@@ -52,15 +45,10 @@ use([
LineChart,
GridComponent,
TooltipComponent,
TitleComponent,
LegendComponent,
]);
const option = computed<EChartsOption>(() => ({
title: {
text: props.title,
subtext: props.subtitle,
},
legend: {
data: props.data.map((series) => series.label),
},

View File

@@ -25,10 +25,11 @@ defineProps<{
align-items: center;
height: 4.4rem;
padding-right: 1.5rem;
padding-left: 1rem;
padding-left: 1.5rem;
white-space: nowrap;
border-radius: 0.8rem;
gap: 1rem;
background-color: var(--color-blue-scale-500);
&.disabled {
color: var(--color-blue-scale-400);

View File

@@ -1,33 +1,44 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<!-- TODO: add small loader with tooltips when stats can be expired -->
<!-- TODO: display the NoData component in case of a data recovery error -->
<LinearChart
:data="data"
:max-value="customMaxValue"
:subtitle="$t('last-week')"
:title="$t('network-throughput')"
:value-formatter="customValueFormatter"
/>
<UiCard class="linear-chart" :color="hasError ? 'error' : undefined">
<UiCardTitle>{{ $t("network-throughput") }}</UiCardTitle>
<UiCardTitle :level="UiCardTitleLevel.Subtitle">
{{ $t("last-week") }}
</UiCardTitle>
<NoDataError v-if="hasError" />
<UiCardSpinner v-else-if="isLoading" />
<LinearChart
v-else
:data="data"
:max-value="customMaxValue"
:value-formatter="customValueFormatter"
/>
</UiCard>
</template>
<script lang="ts" setup>
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import { computed, defineAsyncComponent, inject } from "vue";
import { map } from "lodash-es";
import { useI18n } from "vue-i18n";
import { formatSize } from "@/libs/utils";
import type { HostStats } from "@/libs/xapi-stats";
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import type { LinearChartData } from "@/types/chart";
import { map } from "lodash-es";
import NoDataError from "@/components/NoDataError.vue";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
import { UiCardTitleLevel } from "@/types/enums";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const LinearChart = defineAsyncComponent(
() => import("@/components/charts/LinearChart.vue")
);
const { t } = useI18n();
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
const { hasError, isFetching } = useHostCollection();
const data = computed<LinearChartData>(() => {
const stats = hostLastWeekStats?.stats?.value;
@@ -82,6 +93,25 @@ const data = computed<LinearChartData>(() => {
];
});
const isStatFetched = computed(() => {
const stats = hostLastWeekStats?.stats?.value;
if (stats === undefined) {
return false;
}
return stats.every((host) => {
const hostStats = host.stats;
return (
hostStats != null &&
Object.values(hostStats.pifs["rx"])[0].length +
Object.values(hostStats.pifs["tx"])[0].length ===
data.value[0].data.length + data.value[1].data.length
);
});
});
const isLoading = computed(() => isFetching.value || !isStatFetched.value);
// TODO: improve the way to get the max value of graph
// See: https://github.com/vatesfr/xen-orchestra/pull/6610/files#r1072237279
const customMaxValue = computed(

View File

@@ -1,22 +1,23 @@
<template>
<UiCardTitle
:level="UiCardTitleLevel.SubtitleWithUnderline"
:left="$t('hosts')"
:right="$t('top-#', { n: N_ITEMS })"
subtitle
/>
<NoDataError v-if="hasError" />
<UsageBar v-else :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
</template>
<script lang="ts" setup>
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { computed, inject, type ComputedRef } from "vue";
import { getAvgCpuUsage } from "@/libs/utils";
import { IK_HOST_STATS } from "@/types/injection-keys";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed, type ComputedRef, inject } from "vue";
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { UiCardTitleLevel } from "@/types/enums";
import UsageBar from "@/components/UsageBar.vue";
import { useHostCollection } from "@/stores/xen-api/host.store";
const { hasError } = useHostCollection();

View File

@@ -1,24 +1,33 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<!-- TODO: add small loader with tooltips when stats can be expired -->
<!-- TODO: Display the NoDataError component in case of a data recovery error -->
<LinearChart
:data="data"
:max-value="customMaxValue"
:subtitle="$t('last-week')"
:title="$t('pool-cpu-usage')"
:value-formatter="customValueFormatter"
/>
<UiCard class="linear-chart" :color="hasError ? 'error' : undefined">
<UiCardTitle>{{ $t("pool-cpu-usage") }}</UiCardTitle>
<UiCardTitle :level="UiCardTitleLevel.Subtitle">
{{ $t("last-week") }}
</UiCardTitle>
<NoDataError v-if="hasError" />
<UiCardSpinner v-else-if="isLoading" />
<LinearChart
v-else
:data="data"
:max-value="customMaxValue"
:value-formatter="customValueFormatter"
/>
</UiCard>
</template>
<script lang="ts" setup>
import { useHostCollection } from "@/stores/xen-api/host.store";
import type { HostStats } from "@/libs/xapi-stats";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import type { LinearChartData, ValueFormatter } from "@/types/chart";
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import { sumBy } from "lodash-es";
import { computed, defineAsyncComponent, inject } from "vue";
import type { HostStats } from "@/libs/xapi-stats";
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import type { LinearChartData, ValueFormatter } from "@/types/chart";
import NoDataError from "@/components/NoDataError.vue";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import { sumBy } from "lodash-es";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
import { UiCardTitleLevel } from "@/types/enums";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { useI18n } from "vue-i18n";
const LinearChart = defineAsyncComponent(
@@ -29,8 +38,7 @@ const { t } = useI18n();
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
const { records: hosts } = useHostCollection();
const { records: hosts, isFetching, hasError } = useHostCollection();
const customMaxValue = computed(
() => 100 * sumBy(hosts.value, (host) => +host.cpu_info.cpu_count)
);
@@ -79,6 +87,22 @@ const data = computed<LinearChartData>(() => {
},
];
});
const isStatFetched = computed(() => {
const stats = hostLastWeekStats?.stats?.value;
if (stats === undefined) {
return false;
}
return stats.every((host) => {
const hostStats = host.stats;
return (
hostStats != null &&
Object.values(hostStats.cpus)[0].length === data.value[0].data.length
);
});
});
const isLoading = computed(() => isFetching.value || !isStatFetched.value);
const customValueFormatter: ValueFormatter = (value) => `${value}%`;
</script>

View File

@@ -1,6 +1,6 @@
<template>
<UiCardTitle
subtitle
:level="UiCardTitleLevel.SubtitleWithUnderline"
:left="$t('vms')"
:right="$t('top-#', { n: N_ITEMS })"
/>
@@ -9,6 +9,7 @@
</template>
<script lang="ts" setup>
import { type ComputedRef, computed, inject } from "vue";
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
@@ -16,7 +17,7 @@ import { useVmCollection } from "@/stores/xen-api/vm.store";
import { getAvgCpuUsage } from "@/libs/utils";
import { IK_VM_STATS } from "@/types/injection-keys";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed, type ComputedRef, inject } from "vue";
import { UiCardTitleLevel } from "@/types/enums";
const { hasError } = useVmCollection();

View File

@@ -1,6 +1,6 @@
<template>
<UiCardTitle
subtitle
:level="UiCardTitleLevel.SubtitleWithUnderline"
:left="$t('hosts')"
:right="$t('top-#', { n: N_ITEMS })"
/>
@@ -13,6 +13,7 @@ import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { IK_HOST_STATS } from "@/types/injection-keys";
import { type ComputedRef, computed, inject } from "vue";
import { UiCardTitleLevel } from "@/types/enums";
import UsageBar from "@/components/UsageBar.vue";
import { formatSize, parseRamUsage } from "@/libs/utils";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";

View File

@@ -1,37 +1,43 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<!-- TODO: add small loader with tooltips when stats can be expired -->
<!-- TODO: display the NoDataError component in case of a data recovery error -->
<LinearChart
:data="data"
:max-value="customMaxValue"
:subtitle="$t('last-week')"
:title="$t('pool-ram-usage')"
:value-formatter="customValueFormatter"
>
<template #summary>
<SizeStatsSummary :size="currentData.size" :usage="currentData.usage" />
</template>
</LinearChart>
<UiCard class="linear-chart" :color="hasError ? 'error' : undefined">
<UiCardTitle>{{ $t("pool-ram-usage") }}</UiCardTitle>
<UiCardTitle :level="UiCardTitleLevel.Subtitle">
{{ $t("last-week") }}
</UiCardTitle>
<NoDataError v-if="hasError" />
<UiCardSpinner v-else-if="isLoading" />
<LinearChart
v-else
:data="data"
:max-value="customMaxValue"
:value-formatter="customValueFormatter"
/>
<SizeStatsSummary :size="currentData.size" :usage="currentData.usage" />
</UiCard>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, inject } from "vue";
import { formatSize } from "@/libs/utils";
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import type { LinearChartData } from "@/types/chart";
import NoDataError from "@/components/NoDataError.vue";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
import { sumBy } from "lodash-es";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
import { UiCardTitleLevel } from "@/types/enums";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { useHostMetricsCollection } from "@/stores/xen-api/host-metrics.store";
import { formatSize } from "@/libs/utils";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import type { LinearChartData, ValueFormatter } from "@/types/chart";
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import { sumBy } from "lodash-es";
import { computed, defineAsyncComponent, inject } from "vue";
import { useI18n } from "vue-i18n";
const LinearChart = defineAsyncComponent(
() => import("@/components/charts/LinearChart.vue")
);
const { runningHosts } = useHostCollection();
const { runningHosts, isFetching, hasError } = useHostCollection();
const { getHostMemory } = useHostMetricsCollection();
const { t } = useI18n();
@@ -92,6 +98,23 @@ const data = computed<LinearChartData>(() => {
];
});
const customValueFormatter: ValueFormatter = (value) =>
String(formatSize(value));
const isStatFetched = computed(() => {
const stats = hostLastWeekStats?.stats?.value;
if (stats === undefined) {
return false;
}
return stats.every((host) => {
const hostStats = host.stats;
return (
hostStats != null && hostStats.memory.length === data.value[0].data.length
);
});
});
const isLoading = computed(
() => (isFetching.value && !hasError.value) || !isStatFetched.value
);
const customValueFormatter = (value: number) => String(formatSize(value));
</script>

View File

@@ -1,6 +1,6 @@
<template>
<UiCardTitle
subtitle
:level="UiCardTitleLevel.SubtitleWithUnderline"
:left="$t('vms')"
:right="$t('top-#', { n: N_ITEMS })"
/>
@@ -9,14 +9,15 @@
</template>
<script lang="ts" setup>
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { computed, inject, type ComputedRef } from "vue";
import { formatSize, parseRamUsage } from "@/libs/utils";
import { IK_VM_STATS } from "@/types/injection-keys";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed, type ComputedRef, inject } from "vue";
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { UiCardTitleLevel } from "@/types/enums";
import UsageBar from "@/components/UsageBar.vue";
import { useVmCollection } from "@/stores/xen-api/vm.store";
const { hasError } = useVmCollection();

View File

@@ -1,35 +1,40 @@
<template>
<div :class="{ subtitle }" class="ui-section-title">
<component
:is="subtitle ? 'h5' : 'h4'"
v-if="$slots.default || left"
class="left"
>
<div :class="['ui-section-title', tags.left]">
<component :is="tags.left" v-if="$slots.default || left" class="left">
<slot>{{ left }}</slot>
<UiCounter class="count" v-if="count > 0" :value="count" color="info" />
</component>
<component
:is="subtitle ? 'h6' : 'h5'"
v-if="$slots.right || right"
class="right"
>
<component :is="tags.right" v-if="$slots.right || right" class="right">
<slot name="right">{{ right }}</slot>
</component>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import UiCounter from "@/components/ui/UiCounter.vue";
import { UiCardTitleLevel } from "@/types/enums";
withDefaults(
const props = withDefaults(
defineProps<{
subtitle?: boolean;
count?: number;
level?: UiCardTitleLevel;
left?: string;
right?: string;
count?: number;
}>(),
{ count: 0 }
{ count: 0, level: UiCardTitleLevel.Title }
);
const tags = computed(() => {
switch (props.level) {
case UiCardTitleLevel.Subtitle:
return { left: "h6", right: "h6" };
case UiCardTitleLevel.SubtitleWithUnderline:
return { left: "h5", right: "h6" };
default:
return { left: "h4", right: "h5" };
}
});
</script>
<style lang="postcss" scoped>
@@ -37,7 +42,6 @@ withDefaults(
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
--section-title-left-size: 2rem;
--section-title-left-color: var(--color-blue-scale-100);
@@ -46,9 +50,17 @@ withDefaults(
--section-title-right-color: var(--color-extra-blue-base);
--section-title-right-weight: 700;
&.subtitle {
border-bottom: 1px solid var(--color-extra-blue-base);
&.h6 {
margin-bottom: 1rem;
--section-title-left-size: 1.5rem;
--section-title-left-color: var(--color-blue-scale-300);
--section-title-left-weight: 400;
}
&.h5 {
margin-top: 2rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-extra-blue-base);
--section-title-left-size: 1.6rem;
--section-title-left-color: var(--color-extra-blue-base);
--section-title-left-weight: 700;

View File

@@ -1,6 +1,9 @@
<template>
<MenuItem
v-tooltip="!areAllSelectedVmsHalted && $t('selected-vms-in-execution')"
v-tooltip="
!areAllSelectedVmsHalted &&
$t(isSingleAction ? 'vm-is-running' : 'selected-vms-in-execution')
"
:busy="areSomeSelectedVmsCloning"
:disabled="isDisabled"
:icon="faCopy"
@@ -22,6 +25,7 @@ import { computed } from "vue";
const props = defineProps<{
selectedRefs: XenApiVm["$ref"][];
isSingleAction?: boolean;
}>();
const { getByOpaqueRef, isOperationPending } = useVmCollection();

View File

@@ -11,6 +11,23 @@
</template>
<VmActionPowerStateItems :vm-refs="[vm.$ref]" />
</AppMenu>
<AppMenu v-if="vm !== undefined" placement="bottom-end" shadow>
<template #trigger="{ open, isOpen }">
<UiButton
:active="isOpen"
:icon="faEllipsisVertical"
@click="open"
transparent
class="more-actions-button"
v-tooltip="{
placement: 'left',
content: $t('more-actions'),
}"
/>
</template>
<VmActionCopyItem :selected-refs="[vm.$ref]" is-single-action />
<VmActionSnapshotItem :vm-refs="[vm.$ref]" />
</AppMenu>
</template>
</TitleBar>
</template>
@@ -21,11 +38,15 @@ import TitleBar from "@/components/TitleBar.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiButton from "@/components/ui/UiButton.vue";
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
import VmActionSnapshotItem from "@/components/vm/VmActionItems/VmActionSnapshotItem.vue";
import VmActionCopyItem from "@/components/vm/VmActionItems/VmActionCopyItem.vue";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import {
faAngleDown,
faDisplay,
faEllipsisVertical,
faPowerOff,
} from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
@@ -40,3 +61,9 @@ const vm = computed(() =>
const name = computed(() => vm.value?.name_label);
</script>
<style lang="postcss">
.more-actions-button {
font-size: 1.2em;
}
</style>

View File

@@ -10,8 +10,7 @@ export const useChartTheme = () => {
const getColors = () => ({
background: style.getPropertyValue("--background-color-primary"),
title: style.getPropertyValue("--color-blue-scale-100"),
subtitle: style.getPropertyValue("--color-blue-scale-300"),
text: style.getPropertyValue("--color-blue-scale-300"),
splitLine: style.getPropertyValue("--color-blue-scale-400"),
primary: style.getPropertyValue("--color-extra-blue-base"),
secondary: style.getPropertyValue("--color-orange-world-base"),
@@ -28,24 +27,10 @@ export const useChartTheme = () => {
backgroundColor: colors.value.background,
textStyle: {},
grid: {
top: 80,
top: 40,
left: 80,
right: 20,
},
title: {
textStyle: {
color: colors.value.title,
fontFamily: "Poppins, sans-serif",
fontWeight: 500,
fontSize: 20,
},
subtextStyle: {
color: colors.value.subtitle,
fontFamily: "Poppins, sans-serif",
fontWeight: 400,
fontSize: 14,
},
},
line: {
itemStyle: {
borderWidth: 2,
@@ -235,7 +220,7 @@ export const useChartTheme = () => {
},
axisLabel: {
show: true,
color: colors.value.subtitle,
color: colors.value.text,
},
splitLine: {
show: true,
@@ -295,7 +280,7 @@ export const useChartTheme = () => {
},
axisLabel: {
show: true,
color: colors.value.subtitle,
color: colors.value.text,
},
splitLine: {
show: true,
@@ -325,7 +310,7 @@ export const useChartTheme = () => {
left: "right",
top: "bottom",
textStyle: {
color: colors.value.subtitle,
color: colors.value.text,
},
},
tooltip: {

View File

@@ -75,9 +75,12 @@
"following-hosts-unreachable": "The following hosts are unreachable",
"force-reboot": "Force reboot",
"force-shutdown": "Force shutdown",
"fullscreen": "Fullscreen",
"fullscreen-leave": "Leave fullscreen",
"go-back": "Go back",
"here": "Here",
"hosts": "Hosts",
"keep-me-logged": "Keep me logged in",
"language": "Language",
"last-week": "Last week",
"learn-more": "Learn more",
@@ -85,6 +88,7 @@
"loading-hosts": "Loading hosts…",
"log-out": "Log out",
"login": "Login",
"more-actions": "More actions",
"migrate": "Migrate",
"migrate-n-vms": "Migrate 1 VM | Migrate {n} VMs",
"n-hosts-awaiting-patch": "{n} host is awaiting this patch | {n} hosts are awaiting this patch",
@@ -104,7 +108,7 @@
"object": "Object",
"object-not-found": "Object {id} can't be found…",
"on-object": "on {object}",
"open-in-new-window": "Open in new window",
"open-console-in-new-tab": "Open console in new tab",
"or": "Or",
"page-not-found": "This page is not to be found…",
"password": "Password",
@@ -135,6 +139,7 @@
"save": "Save",
"select-destination-host": "Select a destination host",
"selected-vms-in-execution": "Some selected VMs are running",
"send-ctrl-alt-del": "Send Ctrl+Alt+Del",
"send-us-feedback": "Send us feedback",
"settings": "Settings",
"shutdown": "Shutdown",
@@ -172,6 +177,7 @@
"vcpus": "vCPUs",
"vcpus-used": "vCPUs used",
"version": "Version",
"vm-is-running": "The VM is running",
"vms": "VMs",
"xo-lite-under-construction": "XOLite is under construction"
}

View File

@@ -75,9 +75,12 @@
"following-hosts-unreachable": "Les hôtes suivants sont inaccessibles",
"force-reboot": "Forcer le redémarrage",
"force-shutdown": "Forcer l'arrêt",
"fullscreen": "Plein écran",
"fullscreen-leave": "Quitter plein écran",
"go-back": "Revenir en arrière",
"here": "Ici",
"hosts": "Hôtes",
"keep-me-logged": "Rester connecté",
"language": "Langue",
"last-week": "Semaine dernière",
"learn-more": "En savoir plus",
@@ -85,6 +88,7 @@
"loading-hosts": "Chargement des hôtes…",
"log-out": "Se déconnecter",
"login": "Connexion",
"more-actions": "Plus d'actions",
"migrate": "Migrer",
"migrate-n-vms": "Migrer 1 VM | Migrer {n} VMs",
"n-hosts-awaiting-patch": "{n} hôte attend ce patch | {n} hôtes attendent ce patch",
@@ -104,7 +108,7 @@
"object": "Objet",
"object-not-found": "L'objet {id} est introuvable…",
"on-object": "sur {object}",
"open-in-new-window": "Ouvrir dans une nouvelle fenêtre",
"open-console-in-new-tab": "Ouvrir la console dans un nouvel onglet",
"or": "Ou",
"page-not-found": "Cette page est introuvable…",
"password": "Mot de passe",
@@ -135,6 +139,7 @@
"save": "Enregistrer",
"select-destination-host": "Sélectionnez un hôte de destination",
"selected-vms-in-execution": "Certaines VMs sélectionnées sont en cours d'exécution",
"send-ctrl-alt-del": "Envoyer Ctrl+Alt+Suppr",
"send-us-feedback": "Envoyez-nous vos commentaires",
"settings": "Paramètres",
"shutdown": "Arrêter",
@@ -172,6 +177,7 @@
"vcpus": "vCPUs",
"vcpus-used": "vCPUs utilisés",
"version": "Version",
"vm-is-running": "La VM est en cours d'exécution",
"vms": "VMs",
"xo-lite-under-construction": "XOLite est en construction"
}

View File

@@ -1,7 +1,7 @@
import { useBreakpoints, useColorMode } from "@vueuse/core";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { useRoute } from "vue-router";
import { useRoute, useRouter } from "vue-router";
export const useUiStore = defineStore("ui", () => {
const currentHostOpaqueRef = ref();
@@ -14,8 +14,15 @@ export const useUiStore = defineStore("ui", () => {
const isMobile = computed(() => !isDesktop.value);
const router = useRouter();
const route = useRoute();
const hasUi = computed(() => route.query.ui !== "0");
const hasUi = computed<boolean>({
get: () => route.query.ui !== "0",
set: (value: boolean) => {
void router.replace({ query: { ui: value ? undefined : "0" } });
},
});
return {
colorMode,

View File

@@ -2,7 +2,7 @@ import XapiStats from "@/libs/xapi-stats";
import XenApi from "@/libs/xen-api/xen-api";
import { useLocalStorage } from "@vueuse/core";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { computed, ref, watchEffect } from "vue";
const HOST_URL = import.meta.env.PROD
? window.origin
@@ -17,16 +17,24 @@ enum STATUS {
export const useXenApiStore = defineStore("xen-api", () => {
const xenApi = new XenApi(HOST_URL);
const xapiStats = new XapiStats(xenApi);
const currentSessionId = useLocalStorage<string | undefined>(
const storedSessionId = useLocalStorage<string | undefined>(
"sessionId",
undefined
);
const currentSessionId = ref(storedSessionId.value);
const rememberMe = useLocalStorage("rememberMe", false);
const status = ref(STATUS.DISCONNECTED);
const isConnected = computed(() => status.value === STATUS.CONNECTED);
const isConnecting = computed(() => status.value === STATUS.CONNECTING);
const getXapi = () => xenApi;
const getXapiStats = () => xapiStats;
watchEffect(() => {
storedSessionId.value = rememberMe.value
? currentSessionId.value
: undefined;
});
const connect = async (username: string, password: string) => {
status.value = STATUS.CONNECTING;
@@ -63,7 +71,7 @@ export const useXenApiStore = defineStore("xen-api", () => {
async function disconnect() {
await xenApi.disconnect();
currentSessionId.value = null;
currentSessionId.value = undefined;
status.value = STATUS.DISCONNECTED;
}

View File

@@ -16,8 +16,6 @@ type LinearChartData = {
```vue-template
<LinearChart
title="Chart title"
subtitle="Chart subtitle"
:data="data"
/>
```

View File

@@ -1,8 +1,6 @@
<template>
<ComponentStory
:params="[
prop('title').preset('Chart title').widget(),
prop('subtitle').preset('Here is a subtitle').widget(),
prop('data')
.preset(data)
.required()
@@ -58,8 +56,6 @@ const data: LinearChartData = [
const presets = {
"Network bandwidth": {
props: {
title: "Network bandwidth",
subtitle: "Last week",
"value-formatter": byteFormatter,
"max-value": 500000000,
data: [

View File

@@ -0,0 +1,5 @@
export enum UiCardTitleLevel {
Title,
Subtitle,
SubtitleWithUnderline,
}

View File

@@ -7,40 +7,62 @@
{{ $t("power-on-for-console") }}
</div>
<template v-else-if="vm && vmConsole">
<AppMenu horizontal>
<MenuItem
:icon="faArrowUpRightFromSquare"
@click="openInNewTab"
v-if="uiStore.hasUi"
>
{{ $t("open-console-in-new-tab") }}
</MenuItem>
<MenuItem
:icon="
uiStore.hasUi
? faUpRightAndDownLeftFromCenter
: faDownLeftAndUpRightToCenter
"
@click="toggleFullScreen"
>
{{ $t(uiStore.hasUi ? "fullscreen" : "fullscreen-leave") }}
</MenuItem>
<MenuItem
:disabled="!consoleElement"
:icon="faKeyboard"
@click="sendCtrlAltDel"
>
{{ $t("send-ctrl-alt-del") }}
</MenuItem>
</AppMenu>
<RemoteConsole
ref="consoleElement"
:is-console-available="isConsoleAvailable"
:location="vmConsole.location"
class="remote-console"
/>
<div class="open-in-new-window">
<RouterLink
v-if="uiStore.hasUi"
:to="{ query: { ui: '0' } }"
class="link"
target="_blank"
>
<UiIcon :icon="faArrowUpRightFromSquare" />
{{ $t("open-in-new-window") }}
</RouterLink>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import RemoteConsole from "@/components/RemoteConsole.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import { useConsoleCollection } from "@/stores/xen-api/console.store";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { VM_OPERATION, VM_POWER_STATE } from "@/libs/xen-api/xen-api.enums";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { VM_POWER_STATE, VM_OPERATION } from "@/libs/xen-api/xen-api.enums";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useUiStore } from "@/stores/ui.store";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import { useConsoleCollection } from "@/stores/xen-api/console.store";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import {
faArrowUpRightFromSquare,
faDownLeftAndUpRightToCenter,
faKeyboard,
faUpRightAndDownLeftFromCenter,
} from "@fortawesome/free-solid-svg-icons";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import { useRoute, useRouter } from "vue-router";
const STOP_OPERATIONS = [
VM_OPERATION.SHUTDOWN,
@@ -54,6 +76,7 @@ const STOP_OPERATIONS = [
usePageTitleStore().setTitle(useI18n().t("console"));
const router = useRouter();
const route = useRoute();
const uiStore = useUiStore();
@@ -95,14 +118,26 @@ const isConsoleAvailable = computed(() =>
? !isOperationPending(vm.value, STOP_OPERATIONS)
: false
);
const consoleElement = ref();
const sendCtrlAltDel = () => consoleElement.value?.sendCtrlAltDel();
const toggleFullScreen = () => {
uiStore.hasUi = !uiStore.hasUi;
};
const openInNewTab = () => {
const routeData = router.resolve({ query: { ui: "0" } });
window.open(routeData.href, "_blank");
};
</script>
<style lang="postcss" scoped>
.vm-console-view {
display: flex;
align-items: center;
justify-content: center;
height: calc(100% - 14.5rem);
flex-direction: column;
&.no-ui {
height: 100%;
@@ -160,4 +195,9 @@ const isConsoleAvailable = computed(() =>
}
}
}
.vm-console-view:deep(.app-menu) {
background-color: transparent;
align-self: center;
}
</style>

View File

@@ -24,6 +24,8 @@ const serializeError = error => ({
})
export default class Tasks extends EventEmitter {
#logsToClearOnSuccess = new Set()
// contains consolidated logs of all live and finished tasks
#store
@@ -36,6 +38,22 @@ export default class Tasks extends EventEmitter {
this.#tasks.delete(id)
},
onTaskUpdate: async taskLog => {
const { id, status } = taskLog
if (status !== 'pending') {
if (this.#logsToClearOnSuccess.has(id)) {
this.#logsToClearOnSuccess.delete(id)
if (status === 'success') {
try {
await this.#store.del(id)
} catch (error) {
warn('failure on deleting task log from store', { error, taskLog })
}
return
}
}
}
// Error objects are not JSON-ifiable by default
const { result } = taskLog
if (result instanceof Error && result.toJSON === undefined) {
@@ -135,10 +153,13 @@ export default class Tasks extends EventEmitter {
*
* @returns {Task}
*/
create({ name, objectId, userId = this.#app.apiContext?.user?.id, type }) {
create(
{ name, objectId, userId = this.#app.apiContext?.user?.id, type, ...props },
{ clearLogOnSuccess = false } = {}
) {
const tasks = this.#tasks
const task = new Task({ properties: { name, objectId, userId, type }, onProgress: this.#onProgress })
const task = new Task({ properties: { ...props, name, objectId, userId, type }, onProgress: this.#onProgress })
// Use a compact, sortable, string representation of the creation date
//
@@ -152,6 +173,9 @@ export default class Tasks extends EventEmitter {
task.id = id
tasks.set(id, task)
if (clearLogOnSuccess) {
this.#logsToClearOnSuccess.add(id)
}
return task
}

View File

@@ -14,7 +14,7 @@
"url": "https://vates.fr"
},
"license": "AGPL-3.0-or-later",
"version": "0.12.0",
"version": "0.14.0",
"engines": {
"node": ">=15.6"
},

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "@xen-orchestra/proxy",
"version": "0.26.34",
"version": "0.26.37",
"license": "AGPL-3.0-or-later",
"description": "XO Proxy used to remotely execute backup jobs",
"keywords": [
@@ -32,13 +32,13 @@
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.42.1",
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/backups": "^0.43.2",
"@xen-orchestra/fs": "^4.1.1",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/mixin": "^0.1.0",
"@xen-orchestra/mixins": "^0.12.0",
"@xen-orchestra/mixins": "^0.14.0",
"@xen-orchestra/self-signed": "^0.1.3",
"@xen-orchestra/xapi": "^3.1.0",
"@xen-orchestra/xapi": "^3.3.0",
"ajv": "^8.0.3",
"app-conf": "^2.3.0",
"async-iterator-to-stream": "^1.1.0",

View File

@@ -10,7 +10,7 @@
"@xen-orchestra/log": "^0.6.0",
"lodash": "^4.17.21",
"node-fetch": "^3.3.0",
"vhd-lib": "^4.6.0"
"vhd-lib": "^4.6.1"
},
"engines": {
"node": ">=14"

View File

@@ -5,3 +5,4 @@ export { default as VBD } from './vbd.mjs'
export { default as VDI } from './vdi.mjs'
export { default as VIF } from './vif.mjs'
export { default as VM } from './vm.mjs'
export { default as VTPM } from './vtpm.mjs'

View File

@@ -1,6 +1,8 @@
import { asyncEach } from '@vates/async-each'
import { asyncMap } from '@xen-orchestra/async-map'
import { decorateClass } from '@vates/decorate-with'
import { defer } from 'golike-defer'
import { incorrectState, operationFailed } from 'xo-common/api-errors.js'
import { getCurrentVmUuid } from './_XenStore.mjs'
@@ -31,7 +33,38 @@ class Host {
*
* @param {string} ref - Opaque reference of the host
*/
async smartReboot($defer, ref) {
async smartReboot($defer, ref, bypassBlockedSuspend = false, bypassCurrentVmCheck = false) {
let currentVmRef
try {
currentVmRef = await this.call('VM.get_by_uuid', await getCurrentVmUuid())
} catch (error) {}
const residentVmRefs = await this.getField('host', ref, 'resident_VMs')
const vmsWithSuspendBlocked = await asyncMap(residentVmRefs, ref => this.getRecord('VM', ref)).filter(
vm =>
vm.$ref !== currentVmRef &&
!vm.is_control_domain &&
vm.power_state !== 'Halted' &&
vm.power_state !== 'Suspended' &&
vm.blocked_operations.suspend !== undefined
)
if (!bypassBlockedSuspend && vmsWithSuspendBlocked.length > 0) {
throw incorrectState({ actual: vmsWithSuspendBlocked.map(vm => vm.uuid), expected: [], object: 'suspendBlocked' })
}
if (!bypassCurrentVmCheck && residentVmRefs.includes(currentVmRef)) {
throw operationFailed({
objectId: await this.getField('VM', currentVmRef, 'uuid'),
code: 'xoaOnHost',
})
}
await asyncEach(vmsWithSuspendBlocked, vm => {
$defer(() => vm.update_blocked_operations('suspend', vm.blocked_operations.suspend ?? null))
return vm.update_blocked_operations('suspend', null)
})
const suspendedVms = []
if (await this.getField('host', ref, 'enabled')) {
await this.callAsync('host.disable', ref)
@@ -42,13 +75,8 @@ class Host {
})
}
let currentVmRef
try {
currentVmRef = await this.call('VM.get_by_uuid', await getCurrentVmUuid())
} catch (error) {}
await asyncEach(
await this.getField('host', ref, 'resident_VMs'),
residentVmRefs,
async vmRef => {
if (vmRef === currentVmRef) {
return

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/xapi",
"version": "3.1.0",
"version": "3.3.0",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/xapi",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
@@ -34,7 +34,7 @@
"json-rpc-protocol": "^0.13.2",
"lodash": "^4.17.15",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.6.0",
"vhd-lib": "^4.6.1",
"xo-common": "^0.8.0"
},
"private": false,

View File

@@ -0,0 +1,37 @@
import upperFirst from 'lodash/upperFirst.js'
import { incorrectState } from 'xo-common/api-errors.js'
export default class Vtpm {
async create({ is_unique = false, VM }) {
const pool = this.pool
// If VTPM.create is called on a pool that doesn't support VTPM, the errors aren't explicit.
// See https://github.com/xapi-project/xen-api/issues/5186
if (pool.restrictions.restrict_vtpm !== 'false') {
throw incorrectState({
actual: pool.restrictions.restrict_vtpm,
expected: 'false',
object: pool.uuid,
property: 'restrictions.restrict_vtpm',
})
}
try {
return await this.call('VTPM.create', VM, is_unique)
} catch (error) {
const { code, params } = error
if (code === 'VM_BAD_POWER_STATE') {
const [, expected, actual] = params
// In `VM_BAD_POWER_STATE` errors, the power state is lowercased
throw incorrectState({
actual: upperFirst(actual),
expected: upperFirst(expected),
object: await this.getField('VM', VM, 'uuid'),
property: 'power_state',
})
}
throw error
}
}
}

View File

@@ -1,17 +1,80 @@
# ChangeLog
## **next**
## **5.88.0** (2023-10-31)
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
### Highlights
- [About] For source users, display if their XO is up to date [#5934](https://github.com/vatesfr/xen-orchestra/issues/5934) (PR [#7091](https://github.com/vatesfr/xen-orchestra/pull/7091))
- [Self] Show number of VMs that belong to each Resource Set (PR [#7114](https://github.com/vatesfr/xen-orchestra/pull/7114))
- [VM/New] Possibility to create and attach a _VTPM_ to a VM [#7066](https://github.com/vatesfr/xen-orchestra/issues/7066) [Forum#6578](https://xcp-ng.org/forum/topic/6578/xcp-ng-8-3-public-alpha/109) (PR [#7077](https://github.com/vatesfr/xen-orchestra/pull/7077))
- [XOSTOR] Ability to create a XOSTOR storage (PR [#6983](https://github.com/vatesfr/xen-orchestra/pull/6983))
### Enhancements
- [Host/Advanced] Allow to force _Smart reboot_ if some resident VMs have the suspend operation blocked [Forum#7136](https://xcp-ng.org/forum/topic/7136/suspending-vms-during-host-reboot/23) (PR [#7025](https://github.com/vatesfr/xen-orchestra/pull/7025))
- [Plugin/backup-report] Errors are now listed in XO tasks
- [PIF] Show network name in PIF selectors (PR [#7081](https://github.com/vatesfr/xen-orchestra/pull/7081))
- [VM/Advanced] Possibility to create/delete VTPM [#7066](https://github.com/vatesfr/xen-orchestra/issues/7066) [Forum#6578](https://xcp-ng.org/forum/topic/6578/xcp-ng-8-3-public-alpha/109) (PR [#7085](https://github.com/vatesfr/xen-orchestra/pull/7085))
- [Dashboard/Health] Displays number of VDIs to coalesce (PR [#7111](https://github.com/vatesfr/xen-orchestra/pull/7111))
- [Proxy] Ability to open support tunnel on XO Proxy (PRs [#7126](https://github.com/vatesfr/xen-orchestra/pull/7126) [#7127](https://github.com/vatesfr/xen-orchestra/pull/7127))
- [New network] Remove bonded PIFs from selector when creating network (PR [#7136](https://github.com/vatesfr/xen-orchestra/pull/7136))
- Try to preserve current page across reauthentication (PR [#7013](https://github.com/vatesfr/xen-orchestra/pull/7013))
- [XO-WEB/Forget SR] Changed the modal message and added a confirmation text to be sure the action is understood by the user (PR [#7154](https://github.com/vatesfr/xen-orchestra/pull/7154))
### Bug fixes
- [Rolling Pool Update] After the update, when migrating VMs back to their host, do not migrate VMs that are already on the right host [Forum#7802](https://xcp-ng.org/forum/topic/7802) (PR [#7071](https://github.com/vatesfr/xen-orchestra/pull/7071))
- [RPU] Fix "XenServer credentials not found" when running a Rolling Pool Update on a XenServer pool (PR [#7089](https://github.com/vatesfr/xen-orchestra/pull/7089))
- [Usage report] Fix "Converting circular structure to JSON" error
- [Home] Fix OS icons alignment (PR [#7090](https://github.com/vatesfr/xen-orchestra/pull/7090))
- [SR/Advanced] Fix the total number of VDIs to coalesce by taking into account common chains [#7016](https://github.com/vatesfr/xen-orchestra/issues/7016) (PR [#7098](https://github.com/vatesfr/xen-orchestra/pull/7098))
- Don't require to sign in again in XO after losing connection to XO Server (e.g. when restarting or upgrading XO) (PR [#7103](https://github.com/vatesfr/xen-orchestra/pull/7103))
- [Usage report] Fix "Converting circular structure to JSON" error (PR [#7096](https://github.com/vatesfr/xen-orchestra/pull/7096))
- [Usage report] Fix "Cannot convert undefined or null to object" error (PR [#7092](https://github.com/vatesfr/xen-orchestra/pull/7092))
- [Plugin/transport-xmpp] Fix plugin load
- [Self Service] Fix Self users not being able to snapshot VMs when they're members of a user group (PR [#7129](https://github.com/vatesfr/xen-orchestra/pull/7129))
- [Netbox] Fix "The selected cluster is not assigned to this site" error [Forum#7887](https://xcp-ng.org/forum/topic/7887) (PR [#7124](https://github.com/vatesfr/xen-orchestra/pull/7124))
- [Backups] Fix `MESSAGE_METHOD_UNKNOWN` during full backup [Forum#7894](https://xcp-ng.org/forum/topic/7894)(PR [#7139](https://github.com/vatesfr/xen-orchestra/pull/7139))
- [Resource Set] Fix error displayed after successful VM addition to resource set PR ([#7144](https://github.com/vatesfr/xen-orchestra/pull/7144))
### Released packages
- @xen-orchestra/fs 4.1.1
- @xen-orchestra/xapi 3.3.0
- @xen-orchestra/mixins 0.14.0
- xo-server-backup-reports 0.18.0
- xo-server-transport-xmpp 0.1.3
- xo-server-usage-report 0.10.5
- @xen-orchestra/backups 0.43.2
- @xen-orchestra/proxy 0.26.37
- xo-cli 0.21.0
- xo-server 5.125.1
- xo-server-netbox 1.3.2
- xo-web 5.127.1
## **5.87.0** (2023-09-29)
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
### Highlights
- [Patches] Support new XenServer Updates system. See [our documentation](https://xen-orchestra.com/docs/updater.html#xenserver-updates). (PR [#7044](https://github.com/vatesfr/xen-orchestra/pull/7044))
- [Host/Advanced] New button to download system logs [#3968](https://github.com/vatesfr/xen-orchestra/issues/3968) (PR [#7048](https://github.com/vatesfr/xen-orchestra/pull/7048))
- [Home/Hosts, Pools] Display host brand and version (PR [#7027](https://github.com/vatesfr/xen-orchestra/pull/7027))
- [SR] Ability to reclaim space [#1204](https://github.com/vatesfr/xen-orchestra/issues/1204) (PR [#7054](https://github.com/vatesfr/xen-orchestra/pull/7054))
- [XOA] New button to restart XO Server directly from the UI (PR [#7056](https://github.com/vatesfr/xen-orchestra/pull/7056))
- [Host/Advanced] Display system disks health based on the _smartctl_ plugin. [#4458](https://github.com/vatesfr/xen-orchestra/issues/4458) (PR [#7060](https://github.com/vatesfr/xen-orchestra/pull/7060))
- [Authentication] Failed attempts are now logged as XO tasks (PR [#7061](https://github.com/vatesfr/xen-orchestra/pull/7061))
- [Backup] Prevent VMs from being migrated while they are backed up (PR [#7024](https://github.com/vatesfr/xen-orchestra/pull/7024))
- [Backup] Prevent VMs from being backed up while they are migrated (PR [#7024](https://github.com/vatesfr/xen-orchestra/pull/7024))
### Enhancements
- [Netbox] Don't delete VMs that have been created manually in XO-synced cluster [Forum#7639](https://xcp-ng.org/forum/topic/7639) (PR [#7008](https://github.com/vatesfr/xen-orchestra/pull/7008))
- [Kubernetes] _Search domains_ field is now optional [#7028](https://github.com/vatesfr/xen-orchestra/pull/7028)
- [Patches] Support new XenServer Updates system. See [our documentation](https://xen-orchestra.com/docs/updater.html#xenserver-updates). (PR [#7044](https://github.com/vatesfr/xen-orchestra/pull/7044))
- [REST API] Hosts' audit and system logs can be downloaded [#3968](https://github.com/vatesfr/xen-orchestra/issues/3968) (PR [#7048](https://github.com/vatesfr/xen-orchestra/pull/7048))
- [Host/Advanced] New button to download system logs [#3968](https://github.com/vatesfr/xen-orchestra/issues/3968) (PR [#7048](https://github.com/vatesfr/xen-orchestra/pull/7048))
- [Home/Hosts, Pools] Display host brand and version (PR [#7027](https://github.com/vatesfr/xen-orchestra/pull/7027))
- [SR] Ability to reclaim space [#1204](https://github.com/vatesfr/xen-orchestra/issues/1204) (PR [#7054](https://github.com/vatesfr/xen-orchestra/pull/7054))
- [XOA] New button to restart XO Server directly from the UI (PR [#7056](https://github.com/vatesfr/xen-orchestra/pull/7056))
### Bug fixes
@@ -22,23 +85,28 @@
- [Backup] Fix `VHDFile implementation is not compatible with encrypted remote` when using VHD directory with encryption (PR [#7045](https://github.com/vatesfr/xen-orchestra/pull/7045))
- [Backup/Mirror] Fix `xo:fs:local WARN lock compromised` when mirroring a Backup Repository to a local/NFS/SMB repository ([#7043](https://github.com/vatesfr/xen-orchestra/pull/7043))
- [Ova import] Fix importing VM with collision in disk position (PR [#7051](https://github.com/vatesfr/xen-orchestra/pull/7051)) (issue [7046](https://github.com/vatesfr/xen-orchestra/issues/7046))
- [Backup/Mirror] Fix backup report not being sent (PR [#7049](https://github.com/vatesfr/xen-orchestra/pull/7049))
- [New VM] Only add MBR to cloud-init drive on Windows VMs to avoid booting issues (e.g. with Talos) (PR [#7050](https://github.com/vatesfr/xen-orchestra/pull/7050))
- [VDI Import] Add the SR name to the corresponding XAPI task (PR [#6979](https://github.com/vatesfr/xen-orchestra/pull/6979))
### Released packages
- vhd-lib 4.6.0
- @xen-orchestra/backups 0.42.1
- @xen-orchestra/proxy 0.26.34
- xo-vmdk-to-vhd 2.5.6
- xo-server 5.123.0
- xo-server-auth-github 0.3.1
- xo-server-auth-google 0.3.1
- xo-server-netbox 1.3.0
- xo-web 5.125.0
- vhd-lib 4.6.1
- @xen-orchestra/xapi 3.2.0
- @xen-orchestra/backups 0.43.0
- @xen-orchestra/backups-cli 1.0.13
- @xen-orchestra/mixins 0.13.0
- @xen-orchestra/proxy 0.26.35
- xo-server 5.124.0
- xo-server-backup-reports 0.17.4
- xo-web 5.126.0
## **5.86.1** (2023-09-07)
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
### Bug fixes
- [User] _Forget all connection tokens_ button should not delete other users' tokens, even when current user is an administrator (PR [#7014](https://github.com/vatesfr/xen-orchestra/pull/7014))
@@ -100,8 +168,6 @@
## **5.85.0** (2023-07-31)
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
### Highlights
- [Import/From VMWare] Support ESXi 6.5+ with snapshot (PR [#6909](https://github.com/vatesfr/xen-orchestra/pull/6909))

View File

@@ -11,9 +11,7 @@
> Users must be able to say: “I had this issue, happy to know it's fixed”
- [Backup/Mirror] Fix backup report not being sent (PR [#7049](https://github.com/vatesfr/xen-orchestra/pull/7049))
- [New VM] Only add MBR to cloud-init drive on Windows VMs to avoid booting issues (e.g. with Talos) (PR [#7050](https://github.com/vatesfr/xen-orchestra/pull/7050))
- [VDI Import] Add the SR name to the corresponding XAPI task (PR [#6979](https://github.com/vatesfr/xen-orchestra/pull/6979))
- [Netbox] Fix VMs' `site` property being unnecessarily updated on some versions of Netbox (PR [#7145](https://github.com/vatesfr/xen-orchestra/pull/7145))
### Packages to release
@@ -31,9 +29,6 @@
<!--packages-start-->
- @xen-orchestra/xapi minor
- xo-server-backup-reports patch
- xo-server patch
- xo-web patch
- xo-server-netbox patch
<!--packages-end-->

View File

@@ -1,11 +1,35 @@
# Xen Orchestra [![Build Status](https://travis-ci.org/vatesfr/xen-orchestra.png?branch=master)](https://travis-ci.org/vatesfr/xen-orchestra)
<h3 align="center"><b>Xen Orchestra</b></h3>
<p align="center"><b>Manage, Backup and Cloudify your XCP-ng/XenServer infrastructure</b></p>
![](http://i.imgur.com/tRffA5y.png)
![](https://repository-images.githubusercontent.com/8077957/6dcf71fd-bad9-4bfa-933f-b466c52d513d)
## Installation
XO (Xen Orchestra) is a complete solution to visualize, manage, backup and delegate your XCP-ng (or XenServer) infrastructure. **No agent** is required for it to work.
XOA or manual install procedure is [available here](https://xen-orchestra.com/docs/installation.html)
It provides a web UI, a CLI and a REST API, while also getting a Terraform provider among other connectors/plugins.
## ⚡️ Quick start
Log in to your account and use the deploy form available from the [Vates website](https://vates.tech/deploy/).
## 📚 Documentation
The official documentation is available at https://xen-orchestra.com/docs
## 🚀 Features
- **Centralized interface**: one Xen Orchestra to rule your entire infrastructure, even across datacenters at various locations
- **Administration and management:** VM creation, management, migration, metrics and statistics, XO proxies for remote sites… XO will become your best friend!
- **Backup & Disaster Recovery:** The backup is an essential component for the security of your infrastructure. With Xen Orchestra, select the backup mode that suits you best and protect your VMs and your business. Rolling snapshot, Full backup & replication, incremental backup & replication, mirror backup, S3 support among many other possibilities!
- **Cloud Enabler:** Xen Orchestra is your cloud initiator for XCP-ng (and XenServer). Group management, resource delegation and easy group administration. The Cloud is yours!
## 📸 Screenshots
![](https://vates.tech/assets/img/illustrations/xen-orchestra-screen-1.png.avif)
![](https://vates.tech/assets/img/illustrations/xen-orchestra-screen-3.png.avif)
![](https://vates.tech/assets/img/illustrations/xen-orchestra-screen-4.png.avif)
## License
AGPL3 © [Vates SAS](http://vates.fr)
AGPL3 © [Vates](http://vates.tech)

View File

@@ -1,17 +1,13 @@
# Xen Orchestra
![](https://repository-images.githubusercontent.com/8077957/6dcf71fd-bad9-4bfa-933f-b466c52d513d)
## Introduction
Welcome to the official Xen Orchestra (XO) documentation.
XO (Xen Orchestra) is a complete solution to visualize, manage, backup and delegate your XCP-ng (or XenServer) infrastructure. **No agent** is required for it to work.
XO is a web interface to visualize and administer your XenServer (or XAPI enabled) hosts. **No agent** is required for it to work.
It aims to be easy to use on any device supporting modern web technologies (HTML 5, CSS 3, JavaScript), such as your desktop computer or your smartphone.
It provides a web UI, a CLI and a REST API, while also getting a Terraform provider among other connectors/plugins.
## Quick start
Log in to your account and use the deploy form available on [Xen Orchestra website](https://xen-orchestra.com/#!/xoa).
More details available on the [installation section](installation.md#xoa).
![Xen Orchestra logo](./assets/logo.png)
Log in to your account and use the deploy form available from [Vates website](https://vates.tech/deploy/)

View File

@@ -362,7 +362,7 @@ XO will try to find the right prefix for each IP address. If it can't find a pre
- Assign it to object types:
- Virtualization > cluster
- Virtualization > virtual machine
- Virtualization > interface`
- Virtualization > interface
![](./assets/customfield.png)

View File

@@ -94,9 +94,9 @@ uri = 'tcp://db:password@hostname:port'
## Proxy for updates and patches
To check if your hosts are up-to-date, we need to access `http://updates.xensource.com/XenServer/updates.xml`.
To check if your hosts are up-to-date, we need to access `https://updates.ops.xenserver.com/xenserver/updates.xml`.
And to download the patches, we need access to `http://support.citrix.com/supportkc/filedownload?`.
And to download the patches, we need access to `https://fileservice.citrix.com/direct/v2/download/secured/support/article/*/downloads/*.zip`.
To do that behind a corporate proxy, just add the `httpProxy` variable to match your current proxy configuration.

View File

@@ -82,13 +82,13 @@ As you may have seen in other parts of the documentation, XO is composed of two
#### NodeJS
XO needs Node.js. **Please always use latest Node LTS**.
XO requires Node.js 18.
We'll consider at this point that you've got a working node on your box. E.g:
```console
$ node -v
v16.14.0
v18.18.0
```
If not, see [this page](https://nodejs.org/en/download/package-manager/) for instructions on how to install Node.
@@ -106,7 +106,7 @@ XO needs the following packages to be installed. Redis is used as a database by
For example, on Debian/Ubuntu:
```sh
apt-get install build-essential redis-server libpng-dev git python3-minimal libvhdi-utils lvm2 cifs-utils
apt-get install build-essential redis-server libpng-dev git python3-minimal libvhdi-utils lvm2 cifs-utils nfs-common
```
On Fedora/CentOS like:

View File

@@ -23,7 +23,7 @@
"node": ">=10"
},
"dependencies": {
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/fs": "^4.1.1",
"cli-progress": "^3.1.0",
"exec-promise": "^0.7.0",
"getopts": "^2.2.3",
@@ -31,7 +31,7 @@
"lodash": "^4.17.21",
"promise-toolbox": "^0.21.0",
"uuid": "^9.0.0",
"vhd-lib": "^4.6.0"
"vhd-lib": "^4.6.1"
},
"scripts": {
"postversion": "npm publish",

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "vhd-lib",
"version": "4.6.0",
"version": "4.6.1",
"license": "AGPL-3.0-or-later",
"description": "Primitives for VHD file handling",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/vhd-lib",
@@ -20,7 +20,7 @@
"@vates/read-chunk": "^1.2.0",
"@vates/stream-reader": "^0.1.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/fs": "^4.1.1",
"@xen-orchestra/log": "^0.6.0",
"async-iterator-to-stream": "^1.0.2",
"decorator-synchronized": "^0.6.0",
@@ -33,7 +33,7 @@
"uuid": "^9.0.0"
},
"devDependencies": {
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/fs": "^4.1.1",
"execa": "^5.0.0",
"get-stream": "^6.0.0",
"rimraf": "^5.0.1",

View File

@@ -25,8 +25,16 @@ async function checkFile(vhdName) {
// Since the qemu-img check command isn't compatible with vhd format, we use
// the convert command to do a check by conversion. Indeed, the conversion will
// fail if the source file isn't a proper vhd format.
await execa('qemu-img', ['convert', '-fvpc', '-Oqcow2', vhdName, 'outputFile.qcow2'])
await fsPromise.unlink('./outputFile.qcow2')
const target = vhdName + '.qcow2'
try {
await execa('qemu-img', ['convert', '-fvpc', '-Oqcow2', vhdName, target])
} finally {
try {
await fsPromise.unlink(target)
} catch (err) {
console.warn(err)
}
}
}
exports.checkFile = checkFile

View File

@@ -8,6 +8,6 @@
"promise-toolbox": "^0.19.2",
"readable-stream": "^3.1.1",
"throttle": "^1.0.3",
"vhd-lib": "^4.6.0"
"vhd-lib": "^4.6.1"
}
}

View File

@@ -130,6 +130,6 @@ async function main(createClient) {
}
export default main
if (!module.parent) {
if (module.parent === null) {
main(require('./').createClient).catch(console.error.bind(console, 'FATAL'))
}

View File

@@ -13,6 +13,7 @@ import humanFormat from 'human-format'
import identity from 'lodash/identity.js'
import isObject from 'lodash/isObject.js'
import micromatch from 'micromatch'
import os from 'os'
import pairs from 'lodash/toPairs.js'
import pick from 'lodash/pick.js'
import prettyMs from 'pretty-ms'
@@ -47,7 +48,7 @@ async function connect() {
return xo
}
async function parseRegisterArgs(args, tokenDescription, acceptToken = false) {
async function parseRegisterArgs(args, tokenDescription, client, acceptToken = false) {
const {
allowUnauthorized,
expiresIn,
@@ -84,21 +85,21 @@ async function parseRegisterArgs(args, tokenDescription, acceptToken = false) {
pw(resolve)
}),
] = opts
result.token = await _createToken({ ...result, description: tokenDescription, email, password })
result.token = await _createToken({ ...result, client, description: tokenDescription, email, password })
}
return result
}
async function _createToken({ allowUnauthorized, description, email, expiresIn, password, url }) {
async function _createToken({ allowUnauthorized, client, description, email, expiresIn, password, url }) {
const xo = new Xo({ rejectUnauthorized: !allowUnauthorized, url })
await xo.open()
try {
await xo.signIn({ email, password })
console.warn('Successfully logged with', xo.user.email)
return await xo.call('token.create', { description, expiresIn }).catch(error => {
// if invalid parameter error, retry without description for backward compatibility
return await xo.call('token.create', { client, description, expiresIn }).catch(error => {
// if invalid parameter error, retry without client and description for backward compatibility
if (error.code === 10) {
return xo.call('token.create', { expiresIn })
}
@@ -219,6 +220,8 @@ function wrap(val) {
// ===================================================================
const PACKAGE_JSON = JSON.parse(readFileSync(new URL('package.json', import.meta.url)))
const help = wrap(
(function (pkg) {
return `Usage:
@@ -355,7 +358,7 @@ $name v$version`.replace(/<([^>]+)>|\$(\w+)/g, function (_, arg, key) {
return pkg[key]
})
})(JSON.parse(readFileSync(new URL('package.json', import.meta.url))))
})(PACKAGE_JSON)
)
// -------------------------------------------------------------------
@@ -422,9 +425,18 @@ async function createToken(args) {
COMMANDS.createToken = createToken
async function register(args) {
const opts = await parseRegisterArgs(args, 'xo-cli --register', true)
let { clientId } = await config.load()
if (clientId === undefined) {
clientId = Math.random().toString(36).slice(2)
}
const { name, version } = PACKAGE_JSON
const label = `${name}@${version} - ${os.hostname()} - ${os.type()} ${os.machine()}`
const opts = await parseRegisterArgs(args, label, { id: clientId }, true)
await config.set({
allowUnauthorized: opts.allowUnauthorized,
clientId,
server: opts.url,
token: opts.token,
})

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "xo-cli",
"version": "0.20.0",
"version": "0.21.0",
"license": "AGPL-3.0-or-later",
"description": "Basic CLI for Xen-Orchestra",
"keywords": [

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server-backup-reports",
"version": "0.17.3",
"version": "0.18.0",
"license": "AGPL-3.0-or-later",
"description": "Backup reports plugin for XO-Server",
"keywords": [

View File

@@ -90,6 +90,8 @@ const formatSpeed = (bytes, milliseconds) =>
})
: 'N/A'
const noop = Function.prototype
const NO_VMS_MATCH_THIS_PATTERN = 'no VMs match this pattern'
const NO_SUCH_OBJECT_ERROR = 'no such object'
const UNHEALTHY_VDI_CHAIN_ERROR = 'unhealthy VDI chain'
@@ -193,13 +195,17 @@ const toMarkdown = parts => {
class BackupReportsXoPlugin {
constructor(xo) {
this._xo = xo
this._eventListener = async (...args) => {
try {
await this._report(...args)
} catch (error) {
logger.warn(error)
}
}
const report = this._report
this._report = (...args) =>
xo.tasks
.create(
{ type: 'xo:xo-server-backup-reports:sendReport', name: 'Sending backup report', runId: args[0] },
{ clearLogOnSuccess: true }
)
.run(() => report.call(this, ...args))
this._eventListener = (...args) => this._report(...args).catch(noop)
}
configure({ toMails, toXmpp }) {
@@ -595,24 +601,28 @@ class BackupReportsXoPlugin {
})
}
_sendReport({ mailReceivers, markdown, subject, success }) {
async _sendReport({ mailReceivers, markdown, subject, success }) {
if (mailReceivers === undefined || mailReceivers.length === 0) {
mailReceivers = this._mailsReceivers
}
const xo = this._xo
return Promise.all([
xo.sendEmail !== undefined &&
xo.sendEmail({
to: mailReceivers,
subject,
markdown,
}),
xo.sendToXmppClient !== undefined &&
xo.sendToXmppClient({
to: this._xmppReceivers,
message: markdown,
}),
const promises = [
mailReceivers !== undefined &&
(xo.sendEmail === undefined
? Promise.reject(new Error('transport-email plugin not enabled'))
: xo.sendEmail({
to: mailReceivers,
subject,
markdown,
})),
this._xmppReceivers !== undefined &&
(xo.sendEmail === undefined
? Promise.reject(new Error('transport-xmpp plugin not enabled'))
: xo.sendToXmppClient({
to: this._xmppReceivers,
message: markdown,
})),
xo.sendSlackMessage !== undefined &&
xo.sendSlackMessage({
message: markdown,
@@ -622,7 +632,22 @@ class BackupReportsXoPlugin {
status: success ? 'OK' : 'CRITICAL',
message: markdown,
}),
])
]
const errors = []
const pushError = errors.push.bind(errors)
await Promise.all(promises.filter(Boolean).map(_ => _.catch(pushError)))
if (errors.length !== 0) {
throw new AggregateError(
errors,
errors
.map(_ => _.message)
.filter(_ => _ != null && _.length !== 0)
.join(', ')
)
}
}
_legacyVmHandler(status) {

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server-netbox",
"version": "1.3.0",
"version": "1.3.2",
"license": "AGPL-3.0-or-later",
"description": "Synchronizes pools managed by Xen Orchestra with Netbox",
"keywords": [

View File

@@ -103,6 +103,8 @@ class Netbox {
}
async test() {
await this.#checkCustomFields()
const randomSuffix = Math.random().toString(36).slice(2, 11)
const name = '[TMP] Xen Orchestra Netbox plugin test - ' + randomSuffix
await this.#request('/virtualization/cluster-types/', 'POST', {
@@ -113,8 +115,6 @@ class Netbox {
})
const nbClusterTypes = await this.#request(`/virtualization/cluster-types/?name=${encodeURIComponent(name)}`)
await this.#checkCustomFields()
if (nbClusterTypes.length !== 1) {
throw new Error('Could not properly write and read Netbox')
}
@@ -144,7 +144,9 @@ class Netbox {
const httpRequest = async () => {
try {
const response = await this.#xo.httpRequest(url, options)
this.#netboxApiVersion = response.headers['api-version']
// API version only follows minor version, which is less precise and is not semver-valid
// See https://github.com/netbox-community/netbox/issues/12879#issuecomment-1589190236
this.#netboxApiVersion = semver.coerce(response.headers['api-version'])?.version ?? undefined
const body = await response.text()
if (body.length > 0) {
return JSON.parse(body)
@@ -336,6 +338,14 @@ class Netbox {
tags: [],
}
// Prior to Netbox v3.3.0: no "site" field on VMs
// v3.3.0: "site" is REQUIRED and MUST be the same as cluster's site
// v3.3.5: "site" is OPTIONAL (auto-assigned in UI, not in API). `null` and cluster's site are accepted.
// v3.4.8: "site" is OPTIONAL and AUTO-ASSIGNED with cluster's site. If passed: ignored except if site is different from cluster's, then error.
if (this.#netboxApiVersion === undefined || semver.satisfies(this.#netboxApiVersion, '3.3.0 - 3.4.7')) {
nbVm.site = find(nbClusters, { id: nbCluster.id })?.site?.id ?? null
}
const distro = xoVm.os_version?.distro
if (distro != null) {
const slug = slugify(distro)
@@ -379,10 +389,7 @@ class Netbox {
nbVm.tags = nbVmTags.sort(({ id: id1 }, { id: id2 }) => (id1 < id2 ? -1 : 1))
// https://netbox.readthedocs.io/en/stable/release-notes/version-2.7/#api-choice-fields-now-use-string-values-3569
if (
this.#netboxApiVersion !== undefined &&
!semver.satisfies(semver.coerce(this.#netboxApiVersion).version, '>=2.7.0')
) {
if (this.#netboxApiVersion !== undefined && !semver.satisfies(this.#netboxApiVersion, '>=2.7.0')) {
nbVm.status = xoVm.power_state === 'Running' ? 1 : 0
}
@@ -395,6 +402,9 @@ class Netbox {
cluster: nbVm.cluster?.id ?? null,
status: nbVm.status?.value ?? null,
platform: nbVm.platform?.id ?? null,
// If site is not supported by Netbox, its value is undefined
// If site is supported by Netbox but empty, its value is null
site: nbVm.site == null ? nbVm.site : nbVm.site.id,
// Sort them so that they can be compared by diff()
tags: nbVm.tags.map(nbTag => ({ id: nbTag.id })).sort(({ id: id1 }, { id: id2 }) => (id1 < id2 ? -1 : 1)),
})

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server-transport-xmpp",
"version": "0.1.2",
"version": "0.1.3",
"license": "AGPL-3.0-or-later",
"description": "Transport Xmpp plugin for XO-Server",
"keywords": [
@@ -29,8 +29,7 @@
"node": ">=10"
},
"dependencies": {
"@xmpp/client": "^0.13.1",
"promise-toolbox": "^0.21.0"
"@xmpp/client": "^0.13.1"
},
"devDependencies": {
"@babel/cli": "^7.0.0",

View File

@@ -1,4 +1,3 @@
import fromEvent from 'promise-toolbox/fromEvent'
import { client, xml } from '@xmpp/client'
// ===================================================================
@@ -56,10 +55,7 @@ class TransportXmppPlugin {
async load() {
this._client = client(this._conf)
this._client.on('error', () => {})
await fromEvent(this._client.connection.socket, 'data')
await fromEvent(this._client, 'online')
await this._client.start()
this._unset = this._set('sendToXmppClient', this._sendToXmppClient)
}

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server-usage-report",
"version": "0.10.4",
"version": "0.10.5",
"license": "AGPL-3.0-or-later",
"description": "Report resources usage with their evolution",
"keywords": [

View File

@@ -12,9 +12,9 @@ import {
filter,
find,
forEach,
get,
isFinite,
map,
mapValues,
orderBy,
round,
values,
@@ -204,6 +204,11 @@ function computeMean(values) {
}
})
// No values to work with, return null
if (n === 0) {
return null
}
return sum / n
}
@@ -226,7 +231,7 @@ function getTop(objects, options) {
object => {
const value = object[opt]
return isNaN(value) ? -Infinity : value
return isNaN(value) || value === null ? -Infinity : value
},
'desc'
).slice(0, 3),
@@ -244,7 +249,9 @@ function computePercentage(curr, prev, options) {
return zipObject(
options,
map(options, opt =>
prev[opt] === 0 || prev[opt] === null ? 'NONE' : `${((curr[opt] - prev[opt]) * 100) / prev[opt]}`
prev[opt] === 0 || prev[opt] === null || curr[opt] === null
? 'NONE'
: `${((curr[opt] - prev[opt]) * 100) / prev[opt]}`
)
)
}
@@ -257,7 +264,15 @@ function getDiff(oldElements, newElements) {
}
function getMemoryUsedMetric({ memory, memoryFree = memory }) {
return map(memory, (value, key) => value - memoryFree[key])
return map(memory, (value, key) => {
const tMemory = value
const tMemoryFree = memoryFree[key]
if (tMemory == null || tMemoryFree == null) {
return null
}
return tMemory - tMemoryFree
})
}
const METRICS_MEAN = {
@@ -274,51 +289,61 @@ const DAYS_TO_KEEP = {
weekly: 7,
monthly: 30,
}
function getLastDays(data, periodicity) {
const daysToKeep = DAYS_TO_KEEP[periodicity]
const expectedData = {}
for (const [key, value] of Object.entries(data)) {
if (Array.isArray(value)) {
// slice only applies to array
expectedData[key] = value.slice(-daysToKeep)
} else {
expectedData[key] = value
}
function getDeepLastValues(data, nValues) {
if (data == null) {
return {}
}
return expectedData
if (Array.isArray(data)) {
return data.slice(-nValues)
}
if (typeof data !== 'object') {
throw new Error('data must be an object or an array')
}
return mapValues(data, value => getDeepLastValues(value, nValues))
}
// ===================================================================
async function getVmsStats({ runningVms, periodicity, xo }) {
const lastNValues = DAYS_TO_KEEP[periodicity]
return orderBy(
await Promise.all(
map(runningVms, async vm => {
const { stats } = await xo.getXapiVmStats(vm, GRANULARITY).catch(error => {
log.warn('Error on fetching VM stats', {
error,
vmId: vm.id,
})
return {
stats: {},
}
})
const stats = getDeepLastValues(
(
await xo.getXapiVmStats(vm, GRANULARITY).catch(error => {
log.warn('Error on fetching VM stats', {
error,
vmId: vm.id,
})
return {
stats: {},
}
})
).stats,
lastNValues
)
const iopsRead = METRICS_MEAN.iops(getLastDays(get(stats.iops, 'r'), periodicity))
const iopsWrite = METRICS_MEAN.iops(getLastDays(get(stats.iops, 'w'), periodicity))
const iopsRead = METRICS_MEAN.iops(stats.iops?.r)
const iopsWrite = METRICS_MEAN.iops(stats.iops?.w)
return {
uuid: vm.uuid,
name: vm.name_label,
addresses: Object.values(vm.addresses),
cpu: METRICS_MEAN.cpu(getLastDays(stats.cpus, periodicity)),
ram: METRICS_MEAN.ram(getLastDays(getMemoryUsedMetric(stats), periodicity)),
diskRead: METRICS_MEAN.disk(getLastDays(get(stats.xvds, 'r'), periodicity)),
diskWrite: METRICS_MEAN.disk(getLastDays(get(stats.xvds, 'w'), periodicity)),
cpu: METRICS_MEAN.cpu(stats.cpus),
ram: METRICS_MEAN.ram(getMemoryUsedMetric(stats)),
diskRead: METRICS_MEAN.disk(stats.xvds?.r),
diskWrite: METRICS_MEAN.disk(stats.xvds?.w),
iopsRead,
iopsWrite,
iopsTotal: iopsRead + iopsWrite,
netReception: METRICS_MEAN.net(getLastDays(get(stats.vifs, 'rx'), periodicity)),
netTransmission: METRICS_MEAN.net(getLastDays(get(stats.vifs, 'tx'), periodicity)),
netReception: METRICS_MEAN.net(stats.vifs?.rx),
netTransmission: METRICS_MEAN.net(stats.vifs?.tx),
}
})
),
@@ -328,27 +353,34 @@ async function getVmsStats({ runningVms, periodicity, xo }) {
}
async function getHostsStats({ runningHosts, periodicity, xo }) {
const lastNValues = DAYS_TO_KEEP[periodicity]
return orderBy(
await Promise.all(
map(runningHosts, async host => {
const { stats } = await xo.getXapiHostStats(host, GRANULARITY).catch(error => {
log.warn('Error on fetching host stats', {
error,
hostId: host.id,
})
return {
stats: {},
}
})
const stats = getDeepLastValues(
(
await xo.getXapiHostStats(host, GRANULARITY).catch(error => {
log.warn('Error on fetching host stats', {
error,
hostId: host.id,
})
return {
stats: {},
}
})
).stats,
lastNValues
)
return {
uuid: host.uuid,
name: host.name_label,
cpu: METRICS_MEAN.cpu(getLastDays(stats.cpus, periodicity)),
ram: METRICS_MEAN.ram(getLastDays(getMemoryUsedMetric(stats), periodicity)),
load: METRICS_MEAN.load(getLastDays(stats.load, periodicity)),
netReception: METRICS_MEAN.net(getLastDays(get(stats.pifs, 'rx'), periodicity)),
netTransmission: METRICS_MEAN.net(getLastDays(get(stats.pifs, 'tx'), periodicity)),
cpu: METRICS_MEAN.cpu(stats.cpus),
ram: METRICS_MEAN.ram(getMemoryUsedMetric(stats)),
load: METRICS_MEAN.load(stats.load),
netReception: METRICS_MEAN.net(stats.pifs?.rx),
netTransmission: METRICS_MEAN.net(stats.pifs?.tx),
}
})
),
@@ -358,6 +390,8 @@ async function getHostsStats({ runningHosts, periodicity, xo }) {
}
async function getSrsStats({ periodicity, xo, xoObjects }) {
const lastNValues = DAYS_TO_KEEP[periodicity]
return orderBy(
await asyncMapSettled(
filter(xoObjects, obj => obj.type === 'SR' && obj.size > 0 && obj.$PBDs.length > 0),
@@ -371,18 +405,23 @@ async function getSrsStats({ periodicity, xo, xoObjects }) {
name += ` (${container.name_label})`
}
const { stats } = await xo.getXapiSrStats(sr.id, GRANULARITY).catch(error => {
log.warn('Error on fetching SR stats', {
error,
srId: sr.id,
})
return {
stats: {},
}
})
const stats = getDeepLastValues(
(
await xo.getXapiSrStats(sr.id, GRANULARITY).catch(error => {
log.warn('Error on fetching SR stats', {
error,
srId: sr.id,
})
return {
stats: {},
}
})
).stats,
lastNValues
)
const iopsRead = computeMean(getLastDays(get(stats.iops, 'r'), periodicity))
const iopsWrite = computeMean(getLastDays(get(stats.iops, 'w'), periodicity))
const iopsRead = computeMean(stats.iops?.r)
const iopsWrite = computeMean(stats.iops?.w)
return {
uuid: sr.uuid,
@@ -477,7 +516,7 @@ async function getHostsMissingPatches({ runningHosts, xo }) {
.getXapi(host)
.listMissingPatches(host._xapiId)
.catch(error => {
console.error('[WARN] error on fetching hosts missing patches:', JSON.stringify(error))
log.warn('Error on fetching hosts missing patches', { error })
return []
})
@@ -741,7 +780,7 @@ class UsageReportPlugin {
try {
await this._sendReport(true)
} catch (error) {
console.error('[WARN] scheduled function:', (error && error.stack) || error)
log.warn('Scheduled usage report error', { error })
}
})

View File

@@ -172,6 +172,7 @@ ignoreVmSnapshotResources = false
restartHostTimeout = '20 minutes'
maxUncoalescedVdis = 1
vdiExportConcurrency = 12
vmEvacuationConcurrency = 3
vmExportConcurrency = 2
vmSnapshotConcurrency = 2

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "xo-server",
"version": "5.123.0",
"version": "5.125.1",
"license": "AGPL-3.0-or-later",
"description": "Server part of Xen-Orchestra",
"keywords": [
@@ -41,18 +41,18 @@
"@vates/predicates": "^1.1.0",
"@vates/read-chunk": "^1.2.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.42.1",
"@xen-orchestra/backups": "^0.43.2",
"@xen-orchestra/cron": "^1.0.6",
"@xen-orchestra/defined": "^0.0.1",
"@xen-orchestra/emit-async": "^1.0.0",
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/fs": "^4.1.1",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/mixin": "^0.1.0",
"@xen-orchestra/mixins": "^0.12.0",
"@xen-orchestra/mixins": "^0.14.0",
"@xen-orchestra/self-signed": "^0.1.3",
"@xen-orchestra/template": "^0.1.0",
"@xen-orchestra/vmware-explorer": "^0.3.0",
"@xen-orchestra/xapi": "^3.1.0",
"@xen-orchestra/xapi": "^3.3.0",
"ajv": "^8.0.3",
"app-conf": "^2.3.0",
"async-iterator-to-stream": "^1.0.1",
@@ -68,7 +68,6 @@
"cookie-parser": "^1.4.3",
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",
"deptree": "^1.0.0",
"exec-promise": "^0.7.0",
"execa": "^7.0.0",
"express": "^4.16.2",
@@ -128,7 +127,7 @@
"unzipper": "^0.10.5",
"uuid": "^9.0.0",
"value-matcher": "^0.2.0",
"vhd-lib": "^4.6.0",
"vhd-lib": "^4.6.1",
"ws": "^8.2.3",
"xdg-basedir": "^5.1.0",
"xen-api": "^1.3.6",

View File

@@ -69,3 +69,14 @@ html
button.btn.btn-block.btn-info
i.fa.fa-sign-in
| Sign in
script.
(function () {
var d = document
var h = d.location.hash
d.querySelectorAll('a').forEach(a => {
a.href += h
})
d.querySelectorAll('form').forEach(form => {
form.action += h
})
})()

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@xen-orchestra/log'
import assert from 'assert'
import { format } from 'json-rpc-peer'
import { incorrectState } from 'xo-common/api-errors.js'
import backupGuard from './_backupGuard.mjs'
@@ -119,7 +120,15 @@ set.resolve = {
// FIXME: set force to false per default when correctly implemented in
// UI.
export async function restart({ bypassBackupCheck = false, host, force = false, suspendResidentVms }) {
export async function restart({
bypassBackupCheck = false,
host,
force = false,
suspendResidentVms,
bypassBlockedSuspend = force,
bypassCurrentVmCheck = force,
}) {
if (bypassBackupCheck) {
log.warn('host.restart with argument "bypassBackupCheck" set to true', { hostId: host.id })
} else {
@@ -127,7 +136,9 @@ export async function restart({ bypassBackupCheck = false, host, force = false,
}
const xapi = this.getXapi(host)
return suspendResidentVms ? xapi.host_smartReboot(host._xapiRef) : xapi.rebootHost(host._xapiId, force)
return suspendResidentVms
? xapi.host_smartReboot(host._xapiRef, bypassBlockedSuspend, bypassCurrentVmCheck)
: xapi.rebootHost(host._xapiId, force)
}
restart.description = 'restart the host'
@@ -137,6 +148,14 @@ restart.params = {
type: 'boolean',
optional: true,
},
bypassBlockedSuspend: {
type: 'boolean',
optional: true,
},
bypassCurrentVmCheck: {
type: 'boolean',
optional: true,
},
id: { type: 'string' },
force: {
type: 'boolean',
@@ -456,3 +475,73 @@ setControlDomainMemory.params = {
setControlDomainMemory.resolve = {
host: ['id', 'host', 'administrate'],
}
// -------------------------------------------------------------------
/**
*
* @param {{host:HOST}} params
* @returns null if plugin is not installed or don't have the method
* an object device: status on success
*/
export function getSmartctlHealth({ host }) {
return this.getXapi(host).getSmartctlHealth(host._xapiId)
}
getSmartctlHealth.description = 'get smartctl health status'
getSmartctlHealth.params = {
id: { type: 'string' },
}
getSmartctlHealth.resolve = {
host: ['id', 'host', 'view'],
}
/**
*
* @param {{host:HOST}} params
* @returns null if plugin is not installed or don't have the method
* an object device: full device information on success
*/
export function getSmartctlInformation({ host, deviceNames }) {
return this.getXapi(host).getSmartctlInformation(host._xapiId, deviceNames)
}
getSmartctlInformation.description = 'get smartctl information'
getSmartctlInformation.params = {
id: { type: 'string' },
deviceNames: {
type: 'array',
items: {
type: 'string',
},
optional: true,
},
}
getSmartctlInformation.resolve = {
host: ['id', 'host', 'view'],
}
export async function getBlockdevices({ host }) {
const xapi = this.getXapi(host)
if (host.productBrand !== 'XCP-ng') {
throw incorrectState({
actual: host.productBrand,
expected: 'XCP-ng',
object: host.id,
property: 'productBrand',
})
}
return JSON.parse(await xapi.call('host.call_plugin', host._xapiRef, 'lsblk.py', 'list_block_devices', {}))
}
getBlockdevices.params = {
id: { type: 'string' },
}
getBlockdevices.resolve = {
host: ['id', 'host', 'administrate'],
}

View File

@@ -202,6 +202,26 @@ checkHealth.params = {
},
}
export async function openSupportTunnel({ id }) {
await this.callProxyMethod(id, 'appliance.supportTunnel.open')
for (let i = 0; i < 10; ++i) {
const { open, stdout } = await this.callProxyMethod(id, 'appliance.supportTunnel.getState')
if (open && stdout.length !== 0) {
return stdout
}
await new Promise(resolve => setTimeout(resolve, 1e3))
}
throw new Error('could not open support tunnel')
}
openSupportTunnel.permission = 'admin'
openSupportTunnel.params = {
id: { type: 'string' },
}
export function updateApplianceSettings({ id, ...props }) {
return this.updateProxyAppliance(id, props)
}

View File

@@ -6,6 +6,7 @@ import some from 'lodash/some.js'
import ensureArray from '../_ensureArray.mjs'
import { asInteger } from '../xapi/utils.mjs'
import { debounceWithKey } from '../_pDebounceWithKey.mjs'
import { destroy as destroyXostor } from './xostor.mjs'
import { forEach, parseXml } from '../utils.mjs'
// ===================================================================
@@ -56,6 +57,10 @@ const srIsBackingHa = sr => sr.$pool.ha_enabled && some(sr.$pool.$ha_statefiles,
// TODO: find a way to call this "delete" and not destroy
export async function destroy({ sr }) {
const xapi = this.getXapi(sr)
if (sr.SR_type === 'linstor') {
await destroyXostor.call(this, { sr })
return
}
if (sr.SR_type !== 'xosan') {
await xapi.destroySr(sr._xapiId)
return

View File

@@ -1,8 +1,9 @@
// TODO: Prevent token connections from creating tokens.
// TODO: Token permission.
export async function create({ description, expiresIn }) {
export async function create({ client, description, expiresIn }) {
return (
await this.createAuthenticationToken({
client,
description,
expiresIn,
userId: this.apiContext.user.id,
@@ -17,6 +18,15 @@ create.params = {
optional: true,
type: 'string',
},
client: {
description:
'client this authentication token belongs to, if a previous token exists, it will be updated and returned',
optional: true,
type: 'object',
properties: {
id: { description: 'unique identifier of this client', type: 'string' },
},
},
expiresIn: {
optional: true,
type: ['number', 'string'],

View File

@@ -5,6 +5,7 @@ import asyncMapSettled from '@xen-orchestra/async-map/legacy.js'
import { Task } from '@xen-orchestra/mixins/Tasks.mjs'
import concat from 'lodash/concat.js'
import hrp from 'http-request-plus'
import mapKeys from 'lodash/mapKeys.js'
import { createLogger } from '@xen-orchestra/log'
import { defer } from 'golike-defer'
import { format } from 'json-rpc-peer'
@@ -237,6 +238,11 @@ export const create = defer(async function ($defer, params) {
await this.allocIpAddresses(vif.$id, concat(vif.ipv4_allowed, vif.ipv6_allowed)).catch(() => xapi.deleteVif(vif))
}
if (params.createVtpm) {
const vtpmRef = await xapi.VTPM_create({ VM: xapiVm.$ref })
$defer.onFailure(() => xapi.call('VTPM.destroy', vtpmRef))
}
if (params.bootAfterCreate) {
startVmAndDestroyCloudConfigVdi(xapi, xapiVm, cloudConfigVdiUuid, params)
}
@@ -257,6 +263,11 @@ create.params = {
optional: true,
},
createVtpm: {
type: 'boolean',
default: false,
},
networkConfig: {
type: 'string',
optional: true,
@@ -622,6 +633,8 @@ warmMigration.params = {
// -------------------------------------------------------------------
const autoPrefix = (pfx, str) => (str.startsWith(pfx) ? str : pfx + str)
export const set = defer(async function ($defer, params) {
const VM = extract(params, 'VM')
const xapi = this.getXapi(VM)
@@ -646,6 +659,11 @@ export const set = defer(async function ($defer, params) {
await xapi.call('VM.set_suspend_SR', VM._xapiRef, suspendSr === null ? Ref.EMPTY : suspendSr._xapiRef)
}
const xenStoreData = extract(params, 'xenStoreData')
if (xenStoreData !== undefined) {
await this.getXapiObject(VM).update_xenstore_data(mapKeys(xenStoreData, (v, k) => autoPrefix('vm-data/', k)))
}
return xapi.editVm(vmId, params, async (limits, vm) => {
const resourceSet = xapi.xo.getData(vm, 'resourceSet')
@@ -747,6 +765,15 @@ set.params = {
blockedOperations: { type: 'object', optional: true, properties: { '*': { type: ['boolean', 'null', 'string'] } } },
suspendSr: { type: ['string', 'null'], optional: true },
xenStoreData: {
description: 'properties that should be set or deleted (if null) in the VM XenStore',
optional: true,
type: 'object',
additionalProperties: {
type: ['null', 'string'],
},
},
}
set.resolve = {
@@ -946,7 +973,12 @@ export const snapshot = defer(async function (
}
}
if (resourceSet === undefined || !resourceSet.subjects.includes(user.id)) {
// Workaround: allow Resource Set members to snapshot a VM even though they
// don't have operate permissions on the SR(s)
if (
resourceSet === undefined ||
(!resourceSet.subjects.includes(user.id) && !user.groups.some(groupId => resourceSet.subjects.includes(groupId)))
) {
await checkPermissionOnSrs.call(this, vm)
}

View File

@@ -0,0 +1,29 @@
export async function create({ vm }) {
const xapi = this.getXapi(vm)
const vtpmRef = await xapi.VTPM_create({ VM: vm._xapiRef })
return xapi.getField('VTPM', vtpmRef, 'uuid')
}
create.description = 'create a VTPM'
create.params = {
id: { type: 'string' },
}
create.resolve = {
vm: ['id', 'VM', 'administrate'],
}
export async function destroy({ vtpm }) {
await this.getXapi(vtpm).call('VTPM.destroy', vtpm._xapiRef)
}
destroy.description = 'destroy a VTPM'
destroy.params = {
id: { type: 'string' },
}
destroy.resolve = {
vtpm: ['id', 'VTPM', 'administrate'],
}

View File

@@ -0,0 +1,248 @@
import { asyncEach } from '@vates/async-each'
import { createLogger } from '@xen-orchestra/log'
import { defer } from 'golike-defer'
const ENUM_PROVISIONING = {
Thin: 'thin',
Thick: 'thick',
}
const LV_NAME = 'thin_device'
const PROVISIONING = Object.values(ENUM_PROVISIONING)
const VG_NAME = 'linstor_group'
const _XOSTOR_DEPENDENCIES = ['xcp-ng-release-linstor', 'xcp-ng-linstor']
const XOSTOR_DEPENDENCIES = _XOSTOR_DEPENDENCIES.join(',')
const log = createLogger('xo:api:pool')
function pluginCall(xapi, host, plugin, fnName, args) {
return xapi.call('host.call_plugin', host._xapiRef, plugin, fnName, args)
}
async function destroyVolumeGroup(xapi, host, force) {
log.info(`Trying to delete the ${VG_NAME} volume group.`, { hostId: host.id })
return pluginCall(xapi, host, 'lvm.py', 'destroy_volume_group', {
vg_name: VG_NAME,
force: String(force),
})
}
async function installOrUpdateDependencies(host, method = 'install') {
if (method !== 'install' && method !== 'update') {
throw new Error('Invalid method')
}
const xapi = this.getXapi(host)
log.info(`Trying to ${method} XOSTOR dependencies (${XOSTOR_DEPENDENCIES})`, { hostId: host.id })
for (const _package of _XOSTOR_DEPENDENCIES) {
await pluginCall(xapi, host, 'updater.py', method, {
packages: _package,
})
}
}
export function installDependencies({ host }) {
return installOrUpdateDependencies.call(this, host)
}
installDependencies.description = 'Install XOSTOR dependencies'
installDependencies.permission = 'admin'
installDependencies.params = {
host: { type: 'string' },
}
installDependencies.resolve = {
host: ['host', 'host', 'administrate'],
}
export function updateDependencies({ host }) {
return installOrUpdateDependencies.call(this, host, 'update')
}
updateDependencies.description = 'Update XOSTOR dependencies'
updateDependencies.permission = 'admin'
updateDependencies.params = {
host: { type: 'string' },
}
updateDependencies.resolve = {
host: ['host', 'host', 'administrate'],
}
export async function formatDisks({ disks, force, host, ignoreFileSystems, provisioning }) {
const rawDisks = disks.join(',')
const xapi = this.getXapi(host)
const lvmPlugin = (fnName, args) => pluginCall(xapi, host, 'lvm.py', fnName, args)
log.info(`Format disks (${rawDisks}) with force: ${force}`, { hostId: host.id })
if (force) {
await destroyVolumeGroup(xapi, host, force)
}
// ATM we are unable to correctly identify errors (error.code can be used for multiple errors.)
// so we are just adding some suggestion of "why there is this error"
// Error handling will be improved as errors are discovered and understood
try {
await lvmPlugin('create_physical_volume', {
devices: rawDisks,
ignore_existing_filesystems: String(ignoreFileSystems),
force: String(force),
})
} catch (error) {
if (error.code === 'LVM_ERROR(5)') {
error.params = error.params.concat([
"[XO] This error can be triggered if one of the disks is a 'tapdevs' disk.",
'[XO] This error can be triggered if one of the disks have children',
])
}
throw error
}
try {
await lvmPlugin('create_volume_group', {
devices: rawDisks,
vg_name: VG_NAME,
})
} catch (error) {
if (error.code === 'LVM_ERROR(5)') {
error.params = error.params.concat([
"[XO] This error can be triggered if a VG 'linstor_group' is already present on the host.",
])
}
throw error
}
if (provisioning === ENUM_PROVISIONING.Thin) {
await lvmPlugin('create_thin_pool', {
lv_name: LV_NAME,
vg_name: VG_NAME,
})
}
}
formatDisks.description = 'Format disks for a XOSTOR use'
formatDisks.permission = 'admin'
formatDisks.params = {
disks: { type: 'array', items: { type: 'string' } },
force: { type: 'boolean', optional: true, default: false },
host: { type: 'string' },
ignoreFileSystems: { type: 'boolean', optional: true, default: false },
provisioning: { enum: PROVISIONING },
}
formatDisks.resolve = {
host: ['host', 'host', 'administrate'],
}
export const create = defer(async function (
$defer,
{ description, disksByHost, force, ignoreFileSystems, name, provisioning, replication }
) {
const hostIds = Object.keys(disksByHost)
const tmpBoundObjectId = `tmp_${hostIds.join(',')}_${Math.random().toString(32).slice(2)}`
const xostorLicenses = await this.getLicenses({ productType: 'xostor' })
const now = Date.now()
const availableLicenses = xostorLicenses.filter(
({ boundObjectId, expires }) => boundObjectId === undefined && (expires === undefined || expires > now)
)
let license = availableLicenses.find(license => license.productId === 'xostor')
if (license === undefined) {
license = availableLicenses.find(license => license.productId === 'xostor.trial')
}
if (license === undefined) {
license = await this.createBoundXostorTrialLicense({
boundObjectId: tmpBoundObjectId,
})
} else {
await this.bindLicense({
licenseId: license.id,
boundObjectId: tmpBoundObjectId,
})
}
$defer.onFailure(() =>
this.unbindLicense({
licenseId: license.id,
productId: license.productId,
boundObjectId: tmpBoundObjectId,
})
)
const hosts = hostIds.map(hostId => this.getObject(hostId, 'host'))
if (!hosts.every(host => host.$pool === hosts[0].$pool)) {
// we need to do this test to ensure it won't create a partial LV group with only the host of the pool of the first master
throw new Error('All hosts must be in the same pool')
}
const boundInstallDependencies = installDependencies.bind(this)
await asyncEach(hosts, host => boundInstallDependencies({ host }), { stopOnError: false })
const boundFormatDisks = formatDisks.bind(this)
await asyncEach(
hosts,
host => boundFormatDisks({ disks: disksByHost[host.id], host, force, ignoreFileSystems, provisioning }),
{
stopOnError: false,
}
)
const host = hosts[0]
const xapi = this.getXapi(host)
log.info(`Create XOSTOR (${name}) with provisioning: ${provisioning}`)
const srRef = await xapi.SR_create({
device_config: {
'group-name': 'linstor_group/' + LV_NAME,
redundancy: String(replication),
provisioning,
},
host: host.id,
name_description: description,
name_label: name,
shared: true,
type: 'linstor',
})
const srUuid = await xapi.getField('SR', srRef, 'uuid')
await this.rebindLicense({
licenseId: license.id,
oldBoundObjectId: tmpBoundObjectId,
newBoundObjectId: srUuid,
})
return srUuid
})
create.description = 'Create a XOSTOR storage'
create.permission = 'admin'
create.params = {
description: { type: 'string', optional: true, default: 'From XO-server' },
disksByHost: { type: 'object' },
force: { type: 'boolean', optional: true, default: false },
ignoreFileSystems: { type: 'boolean', optional: true, default: false },
name: { type: 'string' },
provisioning: { enum: PROVISIONING },
replication: { type: 'number' },
}
// Also called by sr.destroy if sr.SR_type === 'linstor'
export async function destroy({ sr }) {
if (sr.SR_type !== 'linstor') {
throw new Error('Not a XOSTOR storage')
}
const xapi = this.getXapi(sr)
const hosts = Object.values(xapi.objects.indexes.type.host).map(host => this.getObject(host.uuid, 'host'))
await xapi.destroySr(sr._xapiId)
const license = (await this.getLicenses({ productType: 'xostor' })).find(license => license.boundObjectId === sr.uuid)
await this.unbindLicense({
boundObjectId: license.boundObjectId,
productId: license.productId,
})
return asyncEach(hosts, host => destroyVolumeGroup(xapi, host, true), { stopOnError: false })
}
destroy.description = 'Destroy a XOSTOR storage'
destroy.permission = 'admin'
destroy.params = {
sr: { type: 'string' },
}
destroy.resolve = {
sr: ['sr', 'SR', 'administrate'],
}

View File

@@ -35,6 +35,16 @@ import Collection, { ModelAlreadyExists } from '../collection.mjs'
const VERSION = '20170905'
export default class Redis extends Collection {
// Prepare a record before storing in the database
//
// Input object can be mutated or a new one returned
_serialize(record) {}
// Clean a record after being fetched from the database
//
// Input object can be mutated or a new one returned
_unserialize(record) {}
constructor({ connection, indexes = [], namespace }) {
super()
@@ -85,8 +95,8 @@ export default class Redis extends Collection {
)
const idsIndex = `${prefix}_ids`
await asyncMapSettled(redis.sMembers(idsIndex), id =>
redis.hGetAll(`${prefix}:${id}`).then(values =>
await asyncMapSettled(redis.sMembers(idsIndex), id => {
return this.#get(`${prefix}:${id}`).then(values =>
values == null
? redis.sRem(idsIndex, id) // entry no longer exists
: asyncMapSettled(indexes, index => {
@@ -96,22 +106,23 @@ export default class Redis extends Collection {
}
})
)
)
})
}
_extract(ids) {
const prefix = this.prefix + ':'
const { redis } = this
const models = []
return Promise.all(
map(ids, id => {
return redis.hGetAll(prefix + id).then(model => {
return this.#get(prefix + id).then(model => {
// If empty, consider it a no match.
if (isEmpty(model)) {
return
}
model = this._unserialize(model) ?? model
// Mix the identifier in.
model.id = id
@@ -129,6 +140,12 @@ export default class Redis extends Collection {
return Promise.all(
map(models, async model => {
// don't mutate param
model = JSON.parse(JSON.stringify(model))
// allow specific serialization
model = this._serialize(model) ?? model
// Generate a new identifier if necessary.
if (model.id === undefined) {
model.id = generateUuid()
@@ -144,7 +161,7 @@ export default class Redis extends Collection {
// remove the previous values from indexes
if (indexes.length !== 0) {
const previous = await redis.hGetAll(`${prefix}:${id}`)
const previous = await this.#get(`${prefix}:${id}`)
await asyncMapSettled(indexes, index => {
const value = previous[index]
if (value !== undefined) {
@@ -184,6 +201,22 @@ export default class Redis extends Collection {
)
}
async #get(key) {
const { redis } = this
let model
try {
model = await redis.hGetAll(key)
} catch (error) {
if (!error.message.startsWith('WRONGTYPE')) {
throw error
}
model = await redis.get(key).then(JSON.parse)
}
return model
}
_get(properties) {
const { prefix, redis } = this
@@ -227,7 +260,7 @@ export default class Redis extends Collection {
promise = Promise.all([
promise,
asyncMapSettled(ids, id =>
redis.hGetAll(`${prefix}:${id}`).then(
this.#get(`${prefix}:${id}`).then(
values =>
values != null &&
asyncMapSettled(indexes, index => {

View File

@@ -2,33 +2,21 @@ import isEmpty from 'lodash/isEmpty.js'
import Collection from '../collection/redis.mjs'
import { forEach } from '../utils.mjs'
import { parseProp } from './utils.mjs'
// ===================================================================
export class Groups extends Collection {
_serialize(group) {
let tmp
group.users = isEmpty((tmp = group.users)) ? undefined : JSON.stringify(tmp)
}
_unserialize(group) {
group.users = parseProp('group', group, 'users', [])
}
create(name, provider, providerGroupId) {
return this.add({ name, provider, providerGroupId })
}
async save(group) {
// Serializes.
let tmp
group.users = isEmpty((tmp = group.users)) ? undefined : JSON.stringify(tmp)
return /* await */ this.update(group)
}
async get(properties) {
const groups = await super.get(properties)
// Deserializes.
forEach(groups, group => {
group.users = parseProp('group', group, 'users', [])
})
return groups
}
}

View File

@@ -1,18 +1,26 @@
import Collection from '../collection/redis.mjs'
import { createLogger } from '@xen-orchestra/log'
import { forEach } from '../utils.mjs'
const log = createLogger('xo:plugin-metadata')
// ===================================================================
export class PluginsMetadata extends Collection {
async save({ id, autoload, configuration }) {
return /* await */ this.update({
id,
autoload: autoload ? 'true' : 'false',
configuration: configuration && JSON.stringify(configuration),
})
_serialize(metadata) {
const { autoload, configuration } = metadata
metadata.autoload = JSON.stringify(autoload)
metadata.configuration = JSON.stringify(configuration)
}
_unserialize(metadata) {
const { autoload, configuration } = metadata
metadata.autoload = autoload === 'true'
try {
metadata.configuration = configuration && JSON.parse(configuration)
} catch (error) {
log.warn(`cannot parse pluginMetadata.configuration: ${configuration}`)
metadata.configuration = []
}
}
async merge(id, data) {
@@ -21,27 +29,9 @@ export class PluginsMetadata extends Collection {
throw new Error('no such plugin metadata')
}
return /* await */ this.save({
return /* await */ this.update({
...pluginMetadata,
...data,
})
}
async get(properties) {
const pluginsMetadata = await super.get(properties)
// Deserializes.
forEach(pluginsMetadata, pluginMetadata => {
const { autoload, configuration } = pluginMetadata
pluginMetadata.autoload = autoload === 'true'
try {
pluginMetadata.configuration = configuration && JSON.parse(configuration)
} catch (error) {
log.warn(`cannot parse pluginMetadata.configuration: ${configuration}`)
pluginMetadata.configuration = []
}
})
return pluginsMetadata
}
}

View File

@@ -1,36 +1,26 @@
import Collection from '../collection/redis.mjs'
import { forEach, serializeError } from '../utils.mjs'
import { serializeError } from '../utils.mjs'
import { parseProp } from './utils.mjs'
// ===================================================================
export class Remotes extends Collection {
async get(properties) {
const remotes = await super.get(properties)
forEach(remotes, remote => {
remote.benchmarks = parseProp('remote', remote, 'benchmarks')
remote.enabled = remote.enabled === 'true'
remote.error = parseProp('remote', remote, 'error', remote.error)
})
return remotes
_serialize(remote) {
const { benchmarks } = remote
if (benchmarks !== undefined) {
remote.benchmarks = JSON.stringify(benchmarks)
}
const { error } = remote
if (error !== undefined) {
remote.error = JSON.stringify(typeof error === 'object' ? serializeError(error) : error)
}
}
_update(remotes) {
return super._update(
remotes.map(remote => {
const { benchmarks } = remote
if (benchmarks !== undefined) {
remote.benchmarks = JSON.stringify(benchmarks)
}
const { error } = remote
if (error !== undefined) {
remote.error = JSON.stringify(typeof error === 'object' ? serializeError(error) : error)
}
return remote
})
)
_unserialize(remote) {
remote.benchmarks = parseProp('remote', remote, 'benchmarks')
remote.enabled = remote.enabled === 'true'
remote.error = parseProp('remote', remote, 'error', remote.error)
}
}

View File

@@ -1,11 +1,35 @@
import Collection from '../collection/redis.mjs'
import { forEach, serializeError } from '../utils.mjs'
import { serializeError } from '../utils.mjs'
import { parseProp } from './utils.mjs'
// ===================================================================
export class Servers extends Collection {
_serialize(server) {
server.allowUnauthorized = server.allowUnauthorized ? 'true' : undefined
server.enabled = server.enabled ? 'true' : undefined
const { error } = server
server.error = error != null ? JSON.stringify(serializeError(error)) : undefined
server.readOnly = server.readOnly ? 'true' : undefined
}
_unserialize(server) {
server.allowUnauthorized = server.allowUnauthorized === 'true'
server.enabled = server.enabled === 'true'
if (server.error) {
server.error = parseProp('server', server, 'error', '')
} else {
delete server.error
}
server.readOnly = server.readOnly === 'true'
// see https://github.com/vatesfr/xen-orchestra/issues/6656
if (server.httpProxy === '') {
delete server.httpProxy
}
}
async create(params) {
const { host } = params
@@ -15,38 +39,4 @@ export class Servers extends Collection {
return /* await */ this.add(params)
}
async get(properties) {
const servers = await super.get(properties)
// Deserializes
forEach(servers, server => {
server.allowUnauthorized = server.allowUnauthorized === 'true'
server.enabled = server.enabled === 'true'
if (server.error) {
server.error = parseProp('server', server, 'error', '')
} else {
delete server.error
}
server.readOnly = server.readOnly === 'true'
// see https://github.com/vatesfr/xen-orchestra/issues/6656
if (server.httpProxy === '') {
delete server.httpProxy
}
})
return servers
}
_update(servers) {
servers.forEach(server => {
server.allowUnauthorized = server.allowUnauthorized ? 'true' : undefined
server.enabled = server.enabled ? 'true' : undefined
const { error } = server
server.error = error != null ? JSON.stringify(serializeError(error)) : undefined
server.readOnly = server.readOnly ? 'true' : undefined
})
return super._update(servers)
}
}

View File

@@ -2,4 +2,29 @@ import Collection from '../collection/redis.mjs'
// ===================================================================
export class Tokens extends Collection {}
export class Tokens extends Collection {
_serialize(token) {
const { client } = token
if (client !== undefined) {
const { id, ...rest } = client
token.client_id = id
token.client = JSON.stringify(rest)
}
}
_unserialize(token) {
const { client, client_id } = token
if (client !== undefined) {
token.client = {
...JSON.parse(client),
id: client_id,
}
delete token.client_id
}
if (token.created_at !== undefined) {
token.created_at = +token.created_at
}
token.expiration = +token.expiration
}
}

View File

@@ -6,25 +6,23 @@ import { parseProp } from './utils.mjs'
// ===================================================================
const serialize = user => {
let tmp
return {
...user,
authProviders: isEmpty((tmp = user.authProviders)) ? undefined : JSON.stringify(tmp),
groups: isEmpty((tmp = user.groups)) ? undefined : JSON.stringify(tmp),
preferences: isEmpty((tmp = user.preferences)) ? undefined : JSON.stringify(tmp),
}
}
const deserialize = user => ({
permission: 'none',
...user,
authProviders: parseProp('user', user, 'authProviders', undefined),
groups: parseProp('user', user, 'groups', []),
preferences: parseProp('user', user, 'preferences', {}),
})
export class Users extends Collection {
_serialize(user) {
let tmp
user.authProviders = isEmpty((tmp = user.authProviders)) ? undefined : JSON.stringify(tmp)
user.groups = isEmpty((tmp = user.groups)) ? undefined : JSON.stringify(tmp)
user.preferences = isEmpty((tmp = user.preferences)) ? undefined : JSON.stringify(tmp)
}
_unserialize(user) {
if (user.permission === undefined) {
user.permission = 'none'
}
user.authProviders = parseProp('user', user, 'authProviders', undefined)
user.groups = parseProp('user', user, 'groups', [])
user.preferences = parseProp('user', user, 'preferences', {})
}
async create(properties) {
const { email } = properties
@@ -34,14 +32,6 @@ export class Users extends Collection {
}
// Adds the user to the collection.
return /* await */ this.add(serialize(properties))
}
async save(user) {
return /* await */ this.update(serialize(user))
}
async get(properties) {
return (await super.get(properties)).map(deserialize)
return /* await */ this.add(properties)
}
}

View File

@@ -118,6 +118,7 @@ const TRANSFORMS = {
},
suspendSr: link(obj, 'suspend_image_SR'),
zstdSupported: obj.restrictions.restrict_zstd_export === 'false',
vtpmSupported: obj.restrictions.restrict_vtpm === 'false',
// TODO
// - ? networks = networksByPool.items[pool.id] (network.$pool.id)
@@ -413,6 +414,7 @@ const TRANSFORMS = {
suspendSr: link(obj, 'suspend_SR'),
tags: obj.tags,
VIFs: link(obj, 'VIFs'),
VTPMs: link(obj, 'VTPMs'),
virtualizationMode: domainType,
// deprecated, use pvDriversVersion instead
@@ -509,7 +511,8 @@ const TRANSFORMS = {
// TODO: Should it replace usage?
physical_usage: +obj.physical_utilisation,
allocationStrategy: ALLOCATION_BY_TYPE[srType],
allocationStrategy:
srType === 'linstor' ? obj.$PBDs[0]?.device_config.provisioning ?? 'unknown' : ALLOCATION_BY_TYPE[srType],
current_operations: obj.current_operations,
inMaintenanceMode: obj.other_config['xo:maintenanceState'] !== undefined,
name_description: obj.name_description,
@@ -841,6 +844,14 @@ const TRANSFORMS = {
vgpus: link(obj, 'VGPUs'),
}
},
vtpm(obj) {
return {
type: 'VTPM',
vm: link(obj, 'VM'),
}
},
}
// ===================================================================

View File

@@ -12,6 +12,7 @@ import mixin from '@xen-orchestra/mixin/legacy.js'
import ms from 'ms'
import noop from 'lodash/noop.js'
import once from 'lodash/once.js'
import pick from 'lodash/pick.js'
import tarStream from 'tar-stream'
import uniq from 'lodash/uniq.js'
import { asyncMap } from '@xen-orchestra/async-map'
@@ -65,6 +66,7 @@ export default class Xapi extends XapiBase {
maxUncoalescedVdis,
restartHostTimeout,
vdiExportConcurrency,
vmEvacuationConcurrency,
vmExportConcurrency,
vmMigrationConcurrency = 3,
vmSnapshotConcurrency,
@@ -75,6 +77,7 @@ export default class Xapi extends XapiBase {
this._guessVhdSizeOnImport = guessVhdSizeOnImport
this._maxUncoalescedVdis = maxUncoalescedVdis
this._restartHostTimeout = parseDuration(restartHostTimeout)
this._vmEvacuationConcurrency = vmEvacuationConcurrency
// close event is emitted when the export is canceled via browser. See https://github.com/vatesfr/xen-orchestra/issues/5535
const waitStreamEnd = async stream => fromEvents(await stream, ['end', 'close'])
@@ -191,22 +194,36 @@ export default class Xapi extends XapiBase {
return network.$ref
}
})(pool.other_config['xo:migrationNetwork'])
try {
try {
await (migrationNetworkRef === undefined
? this.callAsync('host.evacuate', hostRef)
: this.callAsync('host.evacuate', hostRef, migrationNetworkRef))
} catch (error) {
if (error.code === 'MESSAGE_PARAMETER_COUNT_MISMATCH') {
log.warn(
'host.evacuate with a migration network is not supported on this host, falling back to evacuating without the migration network',
{ error }
)
await this.callAsync('host.evacuate', hostRef)
} else {
throw error
// host ref
// migration network: optional and might not be supported
// batch size: optional and might not be supported
const params = [hostRef, migrationNetworkRef ?? Ref.EMPTY, this._vmEvacuationConcurrency]
// Removes n params from the end and keeps removing until a non-empty param is found
const popParamsAndTrim = (n = 0) => {
let last
let i = 0
while (i < n || (last = params[params.length - 1]) === undefined || last === Ref.EMPTY) {
if (params.length <= 1) {
throw new Error('not enough params left')
}
params.pop()
i++
}
}
popParamsAndTrim()
try {
await pRetry(() => this.callAsync('host.evacuate', ...params), {
delay: 0,
when: { code: 'MESSAGE_PARAMETER_COUNT_MISMATCH' },
onRetry: error => {
log.warn(error)
popParamsAndTrim(1)
},
})
} catch (error) {
if (!force) {
await this.call('host.enable', hostRef)
@@ -1428,4 +1445,34 @@ export default class Xapi extends XapiBase {
}
}
}
async getSmartctlHealth(hostId) {
try {
return JSON.parse(await this.call('host.call_plugin', this.getObject(hostId).$ref, 'smartctl.py', 'health', {}))
} catch (error) {
if (error.code === 'XENAPI_MISSING_PLUGIN' || error.code === 'UNKNOWN_XENAPI_PLUGIN_FUNCTION') {
return null
} else {
throw error
}
}
}
async getSmartctlInformation(hostId, deviceNames) {
try {
const informations = JSON.parse(
await this.call('host.call_plugin', this.getObject(hostId).$ref, 'smartctl.py', 'information', {})
)
if (deviceNames === undefined) {
return informations
}
return pick(informations, deviceNames)
} catch (error) {
if (error.code === 'XENAPI_MISSING_PLUGIN' || error.code === 'UNKNOWN_XENAPI_PLUGIN_FUNCTION') {
return null
} else {
throw error
}
}
}
}

View File

@@ -59,7 +59,7 @@ const listMissingPatches = debounceWithKey(_listMissingPatches, LISTING_DEBOUNCE
// =============================================================================
export default {
// raw { uuid: patch } map translated from updates.xensource.com/XenServer/updates.xml
// raw { uuid: patch } map translated from updates.ops.xenserver.com/xenserver/updates.xml
// FIXME: should be static
@decorateWith(debounceWithKey, 24 * 60 * 60 * 1000, function () {
return this
@@ -405,6 +405,11 @@ export default {
},
_poolWideInstall: deferrable(async function ($defer, patches, xsCredentials) {
// New XS patching system: https://support.citrix.com/article/CTX473972/upcoming-changes-in-xencenter
if (xsCredentials?.username === undefined || xsCredentials?.apikey === undefined) {
throw new Error('XenServer credentials not found. See https://xen-orchestra.com/docs/updater.html#xenserver-updates')
}
// Legacy XS patches
if (!useUpdateSystem(this.pool.$master)) {
// for each patch: pool_patch.pool_apply
@@ -420,11 +425,6 @@ export default {
}
// ----------
// New XS patching system: https://support.citrix.com/article/CTX473972/upcoming-changes-in-xencenter
if (xsCredentials?.username === undefined || xsCredentials?.apikey === undefined) {
throw new Error('XenServer credentials not found. See https://xen-orchestra.com/docs/updater.html#xenserver-updates')
}
// for each patch: pool_update.introduce → pool_update.pool_apply
for (const p of patches) {
const [vdi] = await Promise.all([this._uploadPatch($defer, p.uuid, xsCredentials), this._ejectToolsIsos()])
@@ -493,7 +493,7 @@ export default {
},
@decorateWith(deferrable)
async rollingPoolUpdate($defer) {
async rollingPoolUpdate($defer, { xsCredentials } = {}) {
const isXcp = _isXcp(this.pool.$master)
if (this.pool.ha_enabled) {
@@ -530,7 +530,7 @@ export default {
// On XS/CH, start by installing patches on all hosts
if (!isXcp) {
log.debug('Install patches')
await this.installPatches()
await this.installPatches({ xsCredentials })
}
// Remember on which hosts the running VMs are
@@ -629,7 +629,13 @@ export default {
continue
}
const residentVms = host.$resident_VMs.map(vm => vm.uuid)
for (const vmId of vmIds) {
if (residentVms.includes(vmId)) {
continue
}
try {
await this.migrateVm(vmId, this, hostId)
} catch (err) {

View File

@@ -49,18 +49,19 @@ export default {
await this._unplugPbd(this.getObject(id))
},
_getVdiChainsInfo(uuid, childrenMap, cache) {
_getVdiChainsInfo(uuid, childrenMap, cache, resultContainer) {
let info = cache[uuid]
if (info === undefined) {
const children = childrenMap[uuid]
const unhealthyLength = children !== undefined && children.length === 1 ? 1 : 0
resultContainer.nUnhealthyVdis += unhealthyLength
const vdi = this.getObjectByUuid(uuid, undefined)
if (vdi === undefined) {
info = { unhealthyLength, missingParent: uuid }
} else {
const parent = vdi.sm_config['vhd-parent']
if (parent !== undefined) {
info = this._getVdiChainsInfo(parent, childrenMap, cache)
info = this._getVdiChainsInfo(parent, childrenMap, cache, resultContainer)
info.unhealthyLength += unhealthyLength
} else {
info = { unhealthyLength }
@@ -76,12 +77,13 @@ export default {
const unhealthyVdis = { __proto__: null }
const children = groupBy(vdis, 'sm_config.vhd-parent')
const vdisWithUnknownVhdParent = { __proto__: null }
const resultContainer = { nUnhealthyVdis: 0 }
const cache = { __proto__: null }
forEach(vdis, vdi => {
if (vdi.managed && !vdi.is_a_snapshot) {
const { uuid } = vdi
const { unhealthyLength, missingParent } = this._getVdiChainsInfo(uuid, children, cache)
const { unhealthyLength, missingParent } = this._getVdiChainsInfo(uuid, children, cache, resultContainer)
if (unhealthyLength !== 0) {
unhealthyVdis[uuid] = unhealthyLength
@@ -95,6 +97,7 @@ export default {
return {
vdisWithUnknownVhdParent,
unhealthyVdis,
...resultContainer,
}
},

View File

@@ -59,10 +59,38 @@ const hasPermission = (actual, expected) => PERMISSIONS[actual] >= PERMISSIONS[e
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
//
// This simplifies compatibility with older version of the API if support
// of the parameter is preferable but not necessary
const ignorableParams = new Set()
for (const key of Object.keys(params)) {
if (key.endsWith('?')) {
const rawKey = key.slice(0, -1)
if (Object.hasOwn(params, rawKey)) {
throw new Error(`conflicting keys: ${rawKey} and ${key}`)
}
params[rawKey] = params[key]
delete params[key]
ignorableParams.add(rawKey)
}
}
const { validate } = method
if (validate !== undefined) {
if (!validate(params)) {
throw errors.invalidParameters(validate.errors)
const vErrors = new Set(validate.errors)
for (const error of vErrors) {
if (error.schemaPath === '#/additionalProperties' && ignorableParams.has(error.params.additionalProperty)) {
delete params[error.params.additionalProperty]
vErrors.delete(error)
}
}
if (vErrors.size !== 0) {
throw errors.invalidParameters(Array.from(vErrors))
}
}
}
}

View File

@@ -7,6 +7,7 @@ import { parseDuration } from '@vates/parse-duration'
import patch from '../patch.mjs'
import { Tokens } from '../models/token.mjs'
import { forEach, generateToken } from '../utils.mjs'
import { replace } from '../sensitive-values.mjs'
// ===================================================================
@@ -14,13 +15,6 @@ const log = createLogger('xo:authentification')
const noSuchAuthenticationToken = id => noSuchObject(id, 'authenticationToken')
const unserialize = token => {
if (token.created_at !== undefined) {
token.created_at = +token.created_at
}
token.expiration = +token.expiration
}
export default class {
constructor(app) {
app.config.watch('authentication', config => {
@@ -85,7 +79,7 @@ export default class {
const tokensDb = (this._tokens = new Tokens({
connection: app._redis,
namespace: 'token',
indexes: ['user_id'],
indexes: ['client_id', 'user_id'],
}))
app.addConfigManager(
@@ -136,41 +130,57 @@ export default class {
}
async authenticateUser(credentials, userData) {
// don't even attempt to authenticate with empty password
const { password } = credentials
if (password === '') {
throw new Error('empty password')
}
const { tasks } = this._app
const task = await tasks.create(
{
type: 'xo:authentication:authenticateUser',
name: 'XO user authentication',
credentials: replace(credentials),
userData,
},
{
// only keep trace of failed attempts
clearLogOnSuccess: true,
}
)
// TODO: remove when email has been replaced by username.
if (credentials.email) {
credentials.username = credentials.email
} else if (credentials.username) {
credentials.email = credentials.username
}
return task.run(async () => {
// don't even attempt to authenticate with empty password
const { password } = credentials
if (password === '') {
throw new Error('empty password')
}
const failures = this._failures
// TODO: remove when email has been replaced by username.
if (credentials.email) {
credentials.username = credentials.email
} else if (credentials.username) {
credentials.email = credentials.username
}
const { username } = credentials
const now = Date.now()
let lastFailure
if (username && (lastFailure = failures[username]) && lastFailure + this._throttlingDelay > now) {
throw new Error('too fast authentication tries')
}
const failures = this._failures
const result = await this._authenticateUser(credentials, userData)
if (result === undefined) {
failures[username] = now
throw invalidCredentials()
}
const { username } = credentials
const now = Date.now()
let lastFailure
if (username && (lastFailure = failures[username]) && lastFailure + this._throttlingDelay > now) {
throw new Error('too fast authentication tries')
}
delete failures[username]
return result
const result = await this._authenticateUser(credentials, userData)
if (result === undefined) {
failures[username] = now
throw invalidCredentials()
}
delete failures[username]
return result
})
}
// -----------------------------------------------------------------
async createAuthenticationToken({ description, expiresIn, userId }) {
async createAuthenticationToken({ client, description, expiresIn, userId }) {
let duration = this._defaultTokenValidity
if (expiresIn !== undefined) {
duration = parseDuration(expiresIn)
@@ -181,8 +191,27 @@ export default class {
}
}
const tokens = this._tokens
const now = Date.now()
const clientId = client?.id
if (clientId !== undefined) {
const token = await tokens.first({ client_id: clientId, user_id: userId })
if (token !== undefined) {
if (token.expiration > now) {
token.description = description
token.expiration = now + duration
tokens.update(token)::ignoreErrors()
return token
}
tokens.remove(token.id)::ignoreErrors()
}
}
const token = {
client,
created_at: now,
description,
id: await generateToken(),
@@ -217,8 +246,6 @@ export default class {
async _getAuthenticationToken(id, properties) {
const token = await this._tokens.first(properties ?? id)
if (token !== undefined) {
unserialize(token)
if (token.expiration > Date.now()) {
return token
}
@@ -244,8 +271,6 @@ export default class {
const tokensDb = this._tokens
const toRemove = []
for (const token of await tokensDb.get({ user_id: userId })) {
unserialize(token)
const { expiration } = token
if (expiration < now) {
toRemove.push(token.id)

View File

@@ -1,5 +1,4 @@
import * as openpgp from 'openpgp'
import DepTree from 'deptree'
import fromCallback from 'promise-toolbox/fromCallback'
import { createLogger } from '@xen-orchestra/log'
import { gunzip, gzip } from 'node:zlib'
@@ -11,7 +10,6 @@ const log = createLogger('xo:config-management')
export default class ConfigManagement {
constructor(app) {
this._app = app
this._depTree = new DepTree()
this._managers = { __proto__: null }
}
@@ -21,7 +19,6 @@ export default class ConfigManagement {
throw new Error(`${id} is already taken`)
}
this._depTree.add(id, dependencies)
this._managers[id] = { dependencies, exporter, importer }
}
@@ -76,15 +73,27 @@ export default class ConfigManagement {
config = JSON.parse(config)
const managers = this._managers
for (const key of this._depTree.resolve()) {
const manager = managers[key]
const imported = new Set()
async function importEntry(id) {
if (!imported.has(id)) {
imported.add(id)
const data = config[key]
if (data !== undefined) {
log.debug(`importing ${key}`)
await manager.importer(data)
await importEntries(managers[id].dependencies)
const data = config[id]
if (data !== undefined) {
log.debug(`importing ${id}`)
await managers[id].importer(data)
}
}
}
async function importEntries(ids) {
for (const id of ids) {
await importEntry(id)
}
}
await importEntries(Object.keys(config))
await this._app.hooks.clean()
}
}

View File

@@ -33,7 +33,7 @@ export default class {
plugins =>
Promise.all(
plugins.map(async plugin => {
await this._pluginsMetadata.save(plugin)
await this._pluginsMetadata.update(plugin)
if (plugin.configuration !== undefined && this._plugins[plugin.id] !== undefined) {
await this.configurePlugin(plugin.id, plugin.configuration)
}
@@ -88,7 +88,7 @@ export default class {
;({ autoload, configuration } = metadata)
} else {
log.info(`[NOTICE] register plugin ${name} for the first time`)
await this._pluginsMetadata.save({
await this._pluginsMetadata.update({
id,
autoload,
})

View File

@@ -62,11 +62,14 @@ export default class Pools {
}
const patchesName = await Promise.all([targetXapi.findPatches(targetRequiredPatches), ...findPatchesPromises])
const { xsCredentials } = _app.apiContext.user.preferences
// Install patches in parallel.
const installPatchesPromises = []
installPatchesPromises.push(
targetXapi.installPatches({
patches: patchesName[0],
xsCredentials,
})
)
let i = 1
@@ -74,6 +77,7 @@ export default class Pools {
installPatchesPromises.push(
sourceXapis[sourceId].installPatches({
patches: patchesName[i++],
xsCredentials,
})
)
}

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