From 1269ddfeae0dcca8d92c77d1b6a98715266b00d3 Mon Sep 17 00:00:00 2001 From: Mathieu <70369997+MathieuRA@users.noreply.github.com> Date: Fri, 28 Oct 2022 16:04:37 +0200 Subject: [PATCH] feat(xo-web/pool): XCP-ng license binding (#6453) --- CHANGELOG.unreleased.md | 1 + packages/xo-web/src/common/intl/messages.js | 16 ++ packages/xo-web/src/common/select-license.js | 76 +++++++--- packages/xo-web/src/common/utils.js | 5 + packages/xo-web/src/common/xo/index.js | 17 +++ .../xo/pool-bind-licenses-modal/ index.js | 29 ++++ packages/xo-web/src/icons.scss | 4 + packages/xo-web/src/xo-app/home/pool-item.js | 25 ++- packages/xo-web/src/xo-app/index.js | 90 ++++++++++- .../xo-web/src/xo-app/pool/tab-advanced.js | 143 +++++++++++++++++- .../xo-web/src/xo-app/xoa/licenses/index.js | 21 ++- 11 files changed, 397 insertions(+), 30 deletions(-) create mode 100644 packages/xo-web/src/common/xo/pool-bind-licenses-modal/ index.js diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 6a6d87f1a..5442b4e7e 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -8,6 +8,7 @@ > Users must be able to say: “Nice enhancement, I'm eager to test it” - [Delta Backup] Use [NBD](https://en.wikipedia.org/wiki/Network_block_device) to download disks (PR [#6461](https://github.com/vatesfr/xen-orchestra/pull/6461)) +- [License] Possibility to bind XCP-ng license to hosts at pool level (PR [#6453](https://github.com/vatesfr/xen-orchestra/pull/6453)) ### Bug fixes diff --git a/packages/xo-web/src/common/intl/messages.js b/packages/xo-web/src/common/intl/messages.js index 2d91a808f..4816913c6 100644 --- a/packages/xo-web/src/common/intl/messages.js +++ b/packages/xo-web/src/common/intl/messages.js @@ -811,6 +811,9 @@ const messages = { noActiveVdi: 'No active VDI', // ----- Pool general ----- + earliestExpirationDate: 'Earliest expiration: {dateString}', + poolPartialSupport: + 'Only {nHostsLicense, number} host{nHostsLicense, plural, one {} other {s}} under license on {nHosts, number} host{nHosts, plural, one {} other {s}}. This means this pool is not supported at all until you license all its hosts.', poolTitleRamUsage: 'Pool RAM usage:', poolRamUsage: '{used} used of {total} ({free} free)', poolMaster: 'Master:', @@ -835,6 +838,9 @@ const messages = { poolHaDisabled: 'Disabled', poolGpuGroups: 'GPU groups', poolRemoteSyslogPlaceHolder: 'Logging host', + poolSupportSourceUsers: 'Pool support not available for source users', + poolSupportXcpngOnly: 'Only available for pool of XCP-ng hosts', + poolLicenseAlreadyFullySupported: 'The pool is already fully supported', setpoolMaster: 'Master', syslogRemoteHost: 'Remote syslog host', defaultMigrationNetwork: 'Default migration network', @@ -2400,6 +2406,16 @@ const messages = { auditInactiveUserActionsRecord: 'User actions recording is currently inactive', // Licenses + allHostsMustBeBound: 'All hosts must be bound to a license', + bound: 'Bound', + bindXcpngLicenses: 'Bind XCP-ng licenses', + confirmBindingOnUnsupportedHost: + 'You are about to bind {nLicenses, number} professional support license{nLicenses, plural, one {} other {s}} on older and unsupported XCP-ng version{nLicenses, plural, one {} other {s}}. Are you sure you want to continue?', + confirmRebindLicenseFromFullySupportedPool: 'The following pools will no longer be fully supported', + licenses: 'Licenses', + licensesBinding: 'Licenses binding', + notEnoughXcpngLicenses: 'Not enough XCP-ng licenses', + notBound: 'Not bound', xosanUnregisteredDisclaimer: 'You are not registered and therefore will not be able to create or manage your XOSAN SRs. {link}', xosanSourcesDisclaimer: diff --git a/packages/xo-web/src/common/select-license.js b/packages/xo-web/src/common/select-license.js index 8fb1aba68..574ab76ed 100644 --- a/packages/xo-web/src/common/select-license.js +++ b/packages/xo-web/src/common/select-license.js @@ -6,16 +6,46 @@ import { injectIntl } from 'react-intl' import { injectState, provideState } from 'reaclette' import { map } from 'lodash' +import { renderXoItemFromId } from './render-xo-item' + +const LicenseOptions = ({ license, formatTime }) => + _( + 'expiresOn', + { + date: + license.expires !== undefined + ? formatTime(license.expires, { + day: 'numeric', + month: 'numeric', + year: 'numeric', + }) + : '', + }, + expirationDate => ( + + ) + ) + const SelectLicense = decorate([ injectIntl, provideState({ computed: { licenses: async (state, { productType }) => { try { - return (await getLicenses({ productType }))?.filter( - ({ boundObjectId, expires }) => - boundObjectId === undefined && (expires === undefined || expires > Date.now()) - ) + const availableLicenses = { + bound: [], + notBound: [], + } + ;(await getLicenses({ productType })).forEach(license => { + if (license.expires === undefined || license.expires > Date.now()) { + availableLicenses[license.boundObjectId === undefined ? 'notBound' : 'bound'].push(license) + } + }) + return availableLicenses } catch (error) { return { licenseError: error } } @@ -23,7 +53,7 @@ const SelectLicense = decorate([ }, }), injectState, - ({ state: { licenses }, intl: { formatTime }, onChange }) => + ({ state: { licenses }, intl: { formatTime }, onChange, showBoundLicenses }) => licenses?.licenseError !== undefined ? ( {_('getLicensesError')} @@ -35,26 +65,22 @@ const SelectLicense = decorate([ {message} ))} - {map(licenses, license => - _( - 'expiresOn', - { - date: - license.expires !== undefined - ? formatTime(license.expires, { - day: 'numeric', - month: 'numeric', - year: 'numeric', - }) - : '', - }, - message => ( - - ) - ) - )} + + {_('notBound', i18nNotBound => ( + + {map(licenses?.notBound, license => ( + + ))} + + ))} + {showBoundLicenses && + _('bound', i18nBound => ( + + {map(licenses?.bound, license => ( + + ))} + + ))} ), ]) diff --git a/packages/xo-web/src/common/utils.js b/packages/xo-web/src/common/utils.js index d2f33eb96..ed78c4d99 100644 --- a/packages/xo-web/src/common/utils.js +++ b/packages/xo-web/src/common/utils.js @@ -146,6 +146,11 @@ export { default as Debug } from './debug' // ------------------------------------------------------------------- // Returns the current XOA Plan or the Plan name if number given +/** + * @deprecated + * + * Use `getXoaPlan` from `xoa-plans` instead + */ export const getXoaPlan = plan => { switch (plan || +process.env.XOA_PLAN) { case 1: diff --git a/packages/xo-web/src/common/xo/index.js b/packages/xo-web/src/common/xo/index.js index 490dac350..0789d3c60 100644 --- a/packages/xo-web/src/common/xo/index.js +++ b/packages/xo-web/src/common/xo/index.js @@ -31,6 +31,7 @@ import store from 'store' import { alert, chooseAction, confirm } from '../modal' import { error, info, success } from '../notification' import { getObject } from 'selectors' +import { getXoaPlan, SOURCES } from '../xoa-plans' import { noop, resolveId, resolveIds } from '../utils' import { connected, @@ -3236,6 +3237,16 @@ export const unlockXosan = (licenseId, srId) => _call('xosan.unlock', { licenseI export const bindLicense = (licenseId, boundObjectId) => _call('xoa.licenses.bind', { licenseId, boundObjectId }) +export const bindXcpngLicense = (licenseId, boundObjectId) => + bindLicense(licenseId, boundObjectId)::tap(subscribeXcpngLicenses.forceRefresh) + +export const rebindLicense = (licenseType, licenseId, oldBoundObjectId, newBoundObjectId) => + _call('xoa.licenses.rebind', { licenseId, oldBoundObjectId, newBoundObjectId })::tap(() => { + if (licenseType === 'xcpng-standard' || licenseType === 'xcpng-enterprise') { + return subscribeXcpngLicenses.forceRefresh() + } + }) + export const selfBindLicense = ({ id, plan, oldXoaId }) => confirm({ title: _('bindXoaLicense'), @@ -3251,6 +3262,12 @@ export const selfBindLicense = ({ id, plan, oldXoaId }) => export const subscribeSelfLicenses = createSubscription(() => _call('xoa.licenses.getSelf')) +export const subscribeXcpngLicenses = createSubscription(() => + getXoaPlan() !== SOURCES && store.getState().user.permission === 'admin' + ? _call('xoa.licenses.getAll', { productType: 'xcpng' }) + : undefined +) + // Support -------------------------------------------------------------------- export const clearXoaCheckCache = () => _call('xoa.clearCheckCache') diff --git a/packages/xo-web/src/common/xo/pool-bind-licenses-modal/ index.js b/packages/xo-web/src/common/xo/pool-bind-licenses-modal/ index.js new file mode 100644 index 000000000..9f51a4126 --- /dev/null +++ b/packages/xo-web/src/common/xo/pool-bind-licenses-modal/ index.js @@ -0,0 +1,29 @@ +import React from 'react' +import SelectLicense from 'select-license' + +import BaseComponent from '../../base-component' +import { Host } from '../../render-xo-item' + +export default class PoolBindLicenseModal extends BaseComponent { + licenseByHost = {} + + get value() { + return this.licenseByHost + } + + onSelectLicense = hostId => event => (this.licenseByHost[hostId] = event.target.value) + + render() { + const { hosts } = this.props + return ( +
+ {hosts.map(({ id }) => ( +
+ + +
+ ))} +
+ ) + } +} diff --git a/packages/xo-web/src/icons.scss b/packages/xo-web/src/icons.scss index 1699a0231..1536f664d 100644 --- a/packages/xo-web/src/icons.scss +++ b/packages/xo-web/src/icons.scss @@ -1,4 +1,8 @@ .xo-icon { + &-pro-support { + @extend .fa; + @extend .fa-file-text; + } &-pool { @extend .fa; @extend .fa-cloud; diff --git a/packages/xo-web/src/xo-app/home/pool-item.js b/packages/xo-web/src/xo-app/home/pool-item.js index 72b52e9d7..df38adf60 100644 --- a/packages/xo-web/src/xo-app/home/pool-item.js +++ b/packages/xo-web/src/xo-app/home/pool-item.js @@ -13,9 +13,13 @@ import { addTag, editPool, getHostMissingPatches, removeTag } from 'xo' import { connectStore, formatSizeShort } from 'utils' import { compact, flatten, map, size, uniq } from 'lodash' import { createGetObjectsOfType, createGetHostMetrics, createSelector } from 'selectors' +import { injectState } from 'reaclette' import styles from './index.css' +import { isAdmin } from '../../common/selectors' +import { ShortDate } from '../../common/utils' + @connectStore(() => { const getPoolHosts = createGetObjectsOfType('host').filter( createSelector( @@ -48,12 +52,14 @@ import styles from './index.css' return { hostMetrics: getHostMetrics, + isAdmin, missingPatches: getMissingPatches, poolHosts: getPoolHosts, nSrs: getNumberOfSrs, nVms: getNumberOfVms, } }) +@injectState export default class PoolItem extends Component { _addTag = tag => addTag(this.props.item.id, tag) _removeTag = tag => removeTag(this.props.item.id, tag) @@ -66,9 +72,25 @@ export default class PoolItem extends Component { this.props.missingPatches.then(patches => this.setState({ missingPatchCount: size(patches) })) } + _getPoolLicenseIcon() { + const { state: reacletteState, item: pool } = this.props + let tooltip + const { icon, earliestExpirationDate, nHostsUnderLicense, nHosts, supportLevel } = + reacletteState.poolLicenseInfoByPoolId[pool.id] + + if (supportLevel === 'total') { + tooltip = _('earliestExpirationDate', { dateString: }) + } + if (supportLevel === 'partial') { + tooltip = _('poolPartialSupport', { nHostsLicense: nHostsUnderLicense, nHosts }) + } + return icon(tooltip) + } + render() { - const { item: pool, expandAll, selected, hostMetrics, poolHosts, nSrs, nVms } = this.props + const { item: pool, expandAll, isAdmin, selected, hostMetrics, poolHosts, nSrs, nVms } = this.props const { missingPatchCount } = this.state + return (
@@ -80,6 +102,7 @@ export default class PoolItem extends Component { + {isAdmin && {this._getPoolLicenseIcon()}}    {missingPatchCount > 0 && ( diff --git a/packages/xo-web/src/xo-app/index.js b/packages/xo-web/src/xo-app/index.js index c7fbc2d41..0c92d2000 100644 --- a/packages/xo-web/src/xo-app/index.js +++ b/packages/xo-web/src/xo-app/index.js @@ -9,9 +9,11 @@ import React from 'react' import Shortcuts from 'shortcuts' import themes from 'themes' import _, { IntlProvider } from 'intl' +// TODO: Replace all `getXoaPlan` by `getXoaPlan` from "xoa-plans" +import { addSubscriptions, connectStore, getXoaPlan, noop, routes } from 'utils' import { blockXoaAccess, isTrialRunning } from 'xoa-updater' import { checkXoa, clearXoaCheckCache } from 'xo' -import { connectStore, getXoaPlan, noop, routes } from 'utils' +import { forEach, groupBy, keyBy, pick } from 'lodash' import { Notification } from 'notification' import { productId2Plan } from 'xoa-plans' import { provideState } from 'reaclette' @@ -48,6 +50,10 @@ import Xosan from './xosan' import Import from './import' import keymap, { help } from '../keymap' +import Tooltip from '../common/tooltip' +import { createCollectionWrapper, createGetObjectsOfType } from '../common/selectors' +import { bindXcpngLicense, rebindLicense, subscribeXcpngLicenses } from '../common/xo' +import { SOURCES } from '../common/xoa-plans' const shortcutManager = new ShortcutManager(keymap) @@ -76,6 +82,20 @@ const BODY_STYLE = { width: '100%', } +export const ICON_POOL_LICENSE = { + total: tooltip => ( + + + + ), + partial: tooltip => ( + + + + ), + any: () => , +} + @routes('home', { about: About, backup: Backup, @@ -101,11 +121,16 @@ const BODY_STYLE = { hub: Hub, proxies: Proxies, }) +@addSubscriptions({ + xcpLicenses: subscribeXcpngLicenses, +}) @connectStore(state => { + const getHosts = createGetObjectsOfType('host') return { trial: state.xoaTrialState, registerNeeded: state.xoaUpdaterState === 'registerNeeded', signedUp: !!state.user, + hosts: getHosts(state), } }) @provideState({ @@ -118,8 +143,71 @@ const BODY_STYLE = { refreshXoaStatus() { this.state.checkXoaCount += 1 }, + async bindXcpngLicenses(_, xcpngLicensesByHost) { + await Promise.all( + map(xcpngLicensesByHost, ({ productId, id, boundObjectId }, hostId) => + boundObjectId !== undefined + ? rebindLicense(productId, id, boundObjectId, hostId) + : bindXcpngLicense(id, hostId) + ) + ) + }, }, computed: { + // In case an host have more than 1 license, it's an issue. + // poolLicenseInfoByPoolId can be impacted because the license expiration check may not yield the right information. + xcpngLicenseByBoundObjectId: (_, { xcpLicenses }) => keyBy(xcpLicenses, 'boundObjectId'), + xcpngLicenseById: (_, { xcpLicenses }) => keyBy(xcpLicenses, 'id'), + hostsByPoolId: createCollectionWrapper((_, { hosts }) => + groupBy( + map(hosts, host => pick(host, ['$poolId', 'id'])), + '$poolId' + ) + ), + poolLicenseInfoByPoolId: ({ hostsByPoolId, xcpngLicenseByBoundObjectId }) => { + const poolLicenseInfoByPoolId = {} + + forEach(hostsByPoolId, (hosts, poolId) => { + const nHosts = hosts.length + let earliestExpirationDate + let nHostsUnderLicense = 0 + + if (getXoaPlan() === SOURCES.name) { + poolLicenseInfoByPoolId[poolId] = { + nHostsUnderLicense, + icon: () => ( + + + + ), + nHosts, + } + return + } + + for (const host of hosts) { + const license = xcpngLicenseByBoundObjectId[host.id] + if (license !== undefined && license.expires > Date.now()) { + nHostsUnderLicense++ + if (earliestExpirationDate === undefined || license.expires < earliestExpirationDate) { + earliestExpirationDate = license.expires + } + } + } + + const supportLevel = nHostsUnderLicense === 0 ? 'any' : nHostsUnderLicense === nHosts ? 'total' : 'partial' + + poolLicenseInfoByPoolId[poolId] = { + earliestExpirationDate, + icon: ICON_POOL_LICENSE[supportLevel], + nHosts, + nHostsUnderLicense, + supportLevel, + } + }) + + return poolLicenseInfoByPoolId + }, xoaStatus: { get({ checkXoaCount }) { // To avoid aggressive minification which would remove destructuration diff --git a/packages/xo-web/src/xo-app/pool/tab-advanced.js b/packages/xo-web/src/xo-app/pool/tab-advanced.js index ce4e9f354..4112abb39 100644 --- a/packages/xo-web/src/xo-app/pool/tab-advanced.js +++ b/packages/xo-web/src/xo-app/pool/tab-advanced.js @@ -13,7 +13,7 @@ import { addSubscriptions, connectStore } from 'utils' import { Container, Row, Col } from 'grid' import { CustomFields } from 'custom-fields' import { injectIntl } from 'react-intl' -import { map } from 'lodash' +import { forEach, map, values } from 'lodash' import { Text, XoSelect } from 'editable' import { createGetObject, @@ -29,10 +29,142 @@ import { setPoolMaster, setRemoteSyslogHost, setRemoteSyslogHosts, + subscribeHvSupportedVersions, subscribePlugins, + subscribeXcpngLicenses, synchronizeNetbox, } from 'xo' +import { injectState, provideState } from 'reaclette' import { SelectSuspendSr } from 'select-suspend-sr' +import { satisfies } from 'semver' + +import decorate from '../../common/apply-decorators' +import PoolBindLicenseModal from '../../common/xo/pool-bind-licenses-modal/ index' +import { confirm } from '../../common/modal' +import { error } from '../../common/notification' +import { Host, Pool } from '../../common/render-xo-item' +import { isAdmin } from '../../common/selectors' +import { SOURCES, getXoaPlan } from '../../common/xoa-plans' + +const BindLicensesButton = decorate([ + addSubscriptions({ + hvSupportedVersions: subscribeHvSupportedVersions, + xcpLicenses: subscribeXcpngLicenses, + }), + connectStore({ + hosts: createGetObjectsOfType('host'), + }), + provideState({ + effects: { + async handleBindLicense() { + const { poolHosts, xcpLicenses } = this.props + + if (xcpLicenses.length < poolHosts.length) { + return error(_('licensesBinding'), _('notEnoughXcpngLicenses')) + } + + const hostsWithoutLicense = poolHosts.filter( + host => this.state.xcpngLicenseByBoundObjectId[host.id] === undefined + ) + const licenseIdByHost = await confirm({ + body: , + icon: 'connect', + title: _('licensesBinding'), + }) + const licensesByHost = {} + + // Pass values into a Set in order to remove duplicated licenseId + const nLicensesToBind = new Set(values(licenseIdByHost)).size + + if (nLicensesToBind !== hostsWithoutLicense.length) { + return error(_('licensesBinding'), _('allHostsMustBeBound')) + } + + const fullySupportedPoolIds = [] + const unsupportedXcpngHostIds = [] + forEach(licenseIdByHost, (licenseId, hostId) => { + const license = this.state.xcpngLicenseById[licenseId] + const boundHost = this.props.hosts[license.boundObjectId] + const hostToBind = this.props.hosts[hostId] + const poolId = boundHost?.$pool + const poolLicenseInfo = this.state.poolLicenseInfoByPoolId[poolId] + licensesByHost[hostId] = license + + if (poolLicenseInfo !== undefined && poolLicenseInfo.supportLevel === 'total' && poolLicenseInfo.nHosts > 1) { + fullySupportedPoolIds.push(poolId) + } + + if (!satisfies(hostToBind.version, this.props.hvSupportedVersions['XCP-ng'])) { + unsupportedXcpngHostIds.push(hostToBind.id) + } + }) + + if (fullySupportedPoolIds.length !== 0) { + await confirm({ + body: ( +
+

{_('confirmRebindLicenseFromFullySupportedPool')}

+
    + {fullySupportedPoolIds.map(poolId => ( +
  • + +
  • + ))} +
+
+ ), + title: _('licensesBinding'), + }) + } + + if (unsupportedXcpngHostIds.length !== 0) { + await confirm({ + body: ( +
+

{_('confirmBindingOnUnsupportedHost', { nLicenses: unsupportedXcpngHostIds.length })}

+
    + {unsupportedXcpngHostIds.map(hostId => ( +
  • + +
  • + ))} +
+
+ ), + title: _('licensesBinding'), + }) + } + + await this.effects.bindXcpngLicenses(licensesByHost) + }, + }, + computed: { + isBindLicenseAvailable: (state, props) => + getXoaPlan() !== SOURCES && state.poolLicenseInfoByPoolId[props.pool.id].supportLevel !== 'total', + isXcpngPool: (_, { poolHosts }) => poolHosts[0].productBrand === 'XCP-ng', + }, + }), + injectState, + ({ effects, state }) => ( + + {_('bindXcpngLicenses')} + + ), +]) @connectStore(() => ({ master: createGetObjectsOfType('host').find((_, { pool }) => ({ @@ -72,6 +204,7 @@ class PoolMaster extends Component { gpuGroups: createGetObjectsOfType('gpuGroup') .filter((_, { pool }) => ({ $pool: pool.id })) .sort(), + isAdmin, migrationNetwork: createGetObject((_, { pool }) => pool.otherConfig['xo:migrationNetwork']), } }) @@ -116,7 +249,7 @@ export default class TabAdvanced extends Component { ) render() { - const { backupNetwork, hosts, gpuGroups, pool, hostsByMultipathing, migrationNetwork } = this.props + const { backupNetwork, hosts, isAdmin, gpuGroups, pool, hostsByMultipathing, migrationNetwork } = this.props const { state } = this const { editRemoteSyslog } = state const { enabled: hostsEnabledMultipathing, disabled: hostsDisabledMultipathing } = hostsByMultipathing @@ -214,6 +347,12 @@ export default class TabAdvanced extends Component { + {isAdmin && ( +
+

{_('licenses')}

+ +
+ )}

{_('poolGpuGroups')}

diff --git a/packages/xo-web/src/xo-app/xoa/licenses/index.js b/packages/xo-web/src/xo-app/xoa/licenses/index.js index 672f30250..59da98e3f 100644 --- a/packages/xo-web/src/xo-app/xoa/licenses/index.js +++ b/packages/xo-web/src/xo-app/xoa/licenses/index.js @@ -171,7 +171,7 @@ export default class Licenses extends Component { return getLicenses() .then(licenses => { - const { proxy, xoa, xosan } = groupBy(licenses, license => { + const { proxy, xcpng, xoa, xosan } = groupBy(licenses, license => { for (const productType of license.productTypes) { if (productType === 'xo') { return 'xoa' @@ -182,12 +182,16 @@ export default class Licenses extends Component { if (productType === 'xoproxy') { return 'proxy' } + if (productType === 'xcpng') { + return 'xcpng' + } } return 'other' }) this.setState({ licenses: { proxy, + xcpng, xoa, xosan, }, @@ -256,6 +260,21 @@ export default class Licenses extends Component { } }) + // --- xcpng + forEach(licenses.xcpng, license => { + // When `expires` is undefined, the license isn't expired + if (!(license.expires < now)) { + products.push({ + buyer: license.buyer, + expires: license.expires, + id: license.id, + product: 'XCP-ng', + type: 'xcpng', + hostId: license.boundObjectId, + }) + } + }) + return products } )