feat(xo-web/pool): XCP-ng license binding (#6453)

This commit is contained in:
Mathieu 2022-10-28 16:04:37 +02:00 committed by GitHub
parent afd47f5522
commit 1269ddfeae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 397 additions and 30 deletions

View File

@ -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

View File

@ -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:

View File

@ -6,36 +6,9 @@ import { injectIntl } from 'react-intl'
import { injectState, provideState } from 'reaclette'
import { map } from 'lodash'
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())
)
} catch (error) {
return { licenseError: error }
}
},
},
}),
injectState,
({ state: { licenses }, intl: { formatTime }, onChange }) =>
licenses?.licenseError !== undefined ? (
<span>
<em className='text-danger'>{_('getLicensesError')}</em>
</span>
) : (
<select className='form-control' onChange={onChange}>
{_('selectLicense', message => (
<option key='none' value='none'>
{message}
</option>
))}
{map(licenses, license =>
import { renderXoItemFromId } from './render-xo-item'
const LicenseOptions = ({ license, formatTime }) =>
_(
'expiresOn',
{
@ -48,13 +21,66 @@ const SelectLicense = decorate([
})
: '',
},
message => (
<option key={license.id} value={license.id}>
{license.id.slice(-4)} {license.expires ? `(${message})` : ''}
expirationDate => (
<option value={license.id}>
<span>
{license.id.slice(-4)} {expirationDate} {license.boundObjectId && renderXoItemFromId(license.boundObjectId)}
</span>
</option>
)
)
)}
const SelectLicense = decorate([
injectIntl,
provideState({
computed: {
licenses: async (state, { productType }) => {
try {
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 }
}
},
},
}),
injectState,
({ state: { licenses }, intl: { formatTime }, onChange, showBoundLicenses }) =>
licenses?.licenseError !== undefined ? (
<span>
<em className='text-danger'>{_('getLicensesError')}</em>
</span>
) : (
<select className='form-control' onChange={onChange}>
{_('selectLicense', message => (
<option key='none' value='none'>
{message}
</option>
))}
{_('notBound', i18nNotBound => (
<optgroup label={i18nNotBound}>
{map(licenses?.notBound, license => (
<LicenseOptions formatTime={formatTime} key={license.id} license={license} />
))}
</optgroup>
))}
{showBoundLicenses &&
_('bound', i18nBound => (
<optgroup label={i18nBound}>
{map(licenses?.bound, license => (
<LicenseOptions formatTime={formatTime} key={license.id} license={license} />
))}
</optgroup>
))}
</select>
),
])

View File

@ -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:

View File

@ -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')

View File

@ -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 (
<div>
{hosts.map(({ id }) => (
<div key={id}>
<Host id={id} link newTab />
<SelectLicense productType='xcpng' showBoundLicenses onChange={this.onSelectLicense(id)} />
</div>
))}
</div>
)
}
}

View File

@ -1,4 +1,8 @@
.xo-icon {
&-pro-support {
@extend .fa;
@extend .fa-file-text;
}
&-pool {
@extend .fa;
@extend .fa-cloud;

View File

@ -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: <ShortDate timestamp={earliestExpirationDate} /> })
}
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 (
<div className={styles.item}>
<BlockLink to={`/pools/${pool.id}`}>
@ -80,6 +102,7 @@ export default class PoolItem extends Component {
<Ellipsis>
<Text value={pool.name_label} onChange={this._setNameLabel} useLongClick />
</Ellipsis>
{isAdmin && <span className='ml-1'>{this._getPoolLicenseIcon()}</span>}
&nbsp;&nbsp;
{missingPatchCount > 0 && (
<span>

View File

@ -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 => (
<Tooltip content={tooltip}>
<Icon icon='pro-support' className='text-success' />
</Tooltip>
),
partial: tooltip => (
<Tooltip content={tooltip}>
<Icon icon='alarm' className='text-warning' />
</Tooltip>
),
any: () => <Icon icon='alarm' className='text-danger' />,
}
@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: () => (
<Tooltip content={_('poolSupportSourceUsers')}>
<Icon icon='unknown-status' className='text-danger' />
</Tooltip>
),
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

View File

@ -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: <PoolBindLicenseModal hosts={hostsWithoutLicense} />,
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: (
<div>
<p>{_('confirmRebindLicenseFromFullySupportedPool')}</p>
<ul>
{fullySupportedPoolIds.map(poolId => (
<li key={poolId}>
<Pool id={poolId} link newTab />
</li>
))}
</ul>
</div>
),
title: _('licensesBinding'),
})
}
if (unsupportedXcpngHostIds.length !== 0) {
await confirm({
body: (
<div>
<p>{_('confirmBindingOnUnsupportedHost', { nLicenses: unsupportedXcpngHostIds.length })}</p>
<ul>
{unsupportedXcpngHostIds.map(hostId => (
<li key={hostId}>
<Host id={hostId} link newTab />
</li>
))}
</ul>
</div>
),
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 }) => (
<ActionButton
btnStyle='primary'
disabled={!state.isXcpngPool || !state.isBindLicenseAvailable}
handler={effects.handleBindLicense}
icon='connect'
tooltip={
getXoaPlan() === SOURCES
? _('poolSupportSourceUsers')
: !state.isXcpngPool
? _('poolSupportXcpngOnly')
: state.isBindLicenseAvailable
? undefined
: _('poolLicenseAlreadyFullySupported')
}
>
{_('bindXcpngLicenses')}
</ActionButton>
),
])
@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 {
</Col>
</Row>
</Container>
{isAdmin && (
<div>
<h3>{_('licenses')}</h3>
<BindLicensesButton poolHosts={hosts} pool={pool} />
</div>
)}
<h3 className='mt-1 mb-1'>{_('poolGpuGroups')}</h3>
<Container>
<Row>

View File

@ -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
}
)