feat(xo-server,xo-web/XOSTOR): XOSTOR implementation (#6983)

See https://xcp-ng.org/forum/topic/5361
This commit is contained in:
Mathieu
2023-10-26 14:58:59 +00:00
committed by GitHub
parent 397b5cd56d
commit 3e5c73528d
18 changed files with 1212 additions and 154 deletions

View File

@@ -16,6 +16,7 @@
- [Self] Show number of VMs that belong to each Resource Set (PR [#7114](https://github.com/vatesfr/xen-orchestra/pull/7114))
- [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))
- [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))
- [XOSTOR] Ability to create a XOSTOR storage (PR [#6983](https://github.com/vatesfr/xen-orchestra/pull/6983))
### Bug fixes

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'
@@ -523,3 +524,24 @@ getSmartctlInformation.params = {
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

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

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

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

View File

@@ -28,6 +28,7 @@ const messages = {
uuid: 'UUID',
vmSrUsage: 'Storage: {used} used of {total} ({free} free)',
new: 'New',
notDefined: 'Not defined',
status: 'Status',
statusConnecting: 'Connecting',
@@ -118,6 +119,8 @@ const messages = {
advancedSettings: 'Advanced settings',
forceUpgrade: 'Force upgrade',
txChecksumming: 'TX checksumming',
thick: 'Thick',
thin: 'Thin',
unknownSize: 'Unknown size',
installedCertificates: 'Installed certificates',
expiry: 'Expiry',
@@ -2489,6 +2492,40 @@ const messages = {
xosanUnderlyingStorageUsage: 'Using {usage}',
xosanCustomIpNetwork: 'Custom IP network (/24)',
xosanIssueHostNotInNetwork: 'Will configure the host xosan network device with a static IP address and plug it in.',
// ----- XOSTOR -----
approximateFinalSize: 'Approximate final size',
cantFetchDisksFromNonXcpngHost: 'Unable to fetch physical disks from non-XCP-ng host',
diskAlreadyMounted: 'The disk is mounted on: {mountpoint}',
diskHasChildren: 'The disk has children',
diskIncompatibleXostor: 'Disk incompatible with XOSTOR',
diskIsReadOnly: 'The disk is Read-Only',
disks: 'Disks',
fieldRequired: '{field} is required',
fieldsMissing: 'Some fields are missing',
hostsNotSameNumberOfDisks: 'Hosts do not have the same number of disks',
isTapdevsDisk: 'This is "tapdevs" disk',
networks: 'Networks',
notXcpPool: 'Not an XCP-ng pool',
noXostorFound: 'No XOSTOR found',
numberOfHosts: 'Number of hosts',
objectDoesNotMeetXostorRequirements: '{object} does not meet XOSTOR requirements. Refer to the documentation.',
onlyShowXostorRequirements: 'Only show {type} that meet XOSTOR requirements',
poolAlreadyHasXostor: 'Pool already has a XOSTOR',
poolNotRecentEnough: 'Not recent enough. Current version: {version}',
replication: 'Replication',
selectDisks: 'Select disk(s)…',
selectedDiskTypeIncompatibleXostor: 'Only disks of type "Disk" and "Raid" are accepted. Selected disk type: {type}.',
storage: 'Storage',
summary: 'Summary',
wrongNumberOfHosts: 'Wrong number of hosts',
xostor: 'XOSTOR',
xostorAvailableInXoa: 'XOSTOR is available in XOA',
xostorIsInBetaStage: 'XOSTOR is currently in its BETA stage. Do not use it in a production environment!',
xostorDiskRequired: 'At least one disk is required',
xostorDisksDropdownLabel: '({nDisks, number} disk{nDisks, plural, one {} other {s}}) {hostname}',
xostorMultipleLicenses: 'This XOSTOR has more than 1 license!',
xostorPackagesWillBeInstalled: '"xcp-ng-release-linstor" and "xcp-ng-linstor" will be installed on each host',
xostorReplicationWarning: 'If a disk dies, you will lose data',
// Hub
hubPage: 'Hub',

View File

@@ -9,7 +9,15 @@ import map from 'lodash/map.js'
import { renderXoItemFromId } from './render-xo-item'
const LicenseOptions = ({ license, formatDate }) => {
const productId = license.productId.split('-')[1]
/**
* license.productId can be:
* - xcpng-enterprise
* - xcpng-standard
* - xo-proxy
* - xostor
* - xostor.trial
*/
const productId = license.productId.startsWith('xostor') ? license.productId : license.productId.split('-')[1]
return (
<option value={license.id}>
<span>

View File

@@ -1940,6 +1940,8 @@ export const importDisks = (disks, sr) =>
)
)
export const getBlockdevices = host => _call('host.getBlockdevices', { id: resolveId(host) })
import ExportVmModalBody from './export-vm-modal' // eslint-disable-line import/first
export const exportVm = async vm => {
const { compression, format } = await confirm({
@@ -3485,6 +3487,10 @@ export const updateXosanPacks = pool =>
return downloadAndInstallXosanPack(pack, pool, { version: pack.version })
})
// XOSTOR --------------------------------------------------------------------
export const createXostorSr = params => _call('xostor.create', params)
// Licenses --------------------------------------------------------------------
export const getLicenses = ({ productType } = {}) => _call('xoa.licenses.getAll', { productType })

View File

@@ -1019,7 +1019,7 @@
@extend .fa-file-archive-o;
}
}
&-menu-xosan {
&-menu-xostor {
@extend .fa;
@extend .fa-database;
}

View File

@@ -46,7 +46,7 @@ import User from './user'
import Vm from './vm'
import Xoa from './xoa'
import XoaUpdates from './xoa/update'
import Xosan from './xosan'
import Xostor from './xostor'
import Import from './import'
import keymap, { help } from '../keymap'
@@ -128,7 +128,7 @@ export const ICON_POOL_LICENSE = {
'vms/new': NewVm,
'vms/:id': Vm,
xoa: Xoa,
xosan: Xosan,
xostor: Xostor,
import: Import,
hub: Hub,
proxies: Proxies,

View File

@@ -478,7 +478,11 @@ export default class Menu extends Component {
label: 'taskMenu',
pill: nResolvedTasks,
},
isAdmin && { to: '/xosan', icon: 'menu-xosan', label: 'xosan' },
isAdmin && {
to: '/xostor',
label: 'xostor',
icon: 'menu-xostor',
},
!noOperatablePools && {
to: '/import/vm',
icon: 'menu-new-import',

View File

@@ -18,7 +18,7 @@ import { get } from '@xen-orchestra/defined'
import { getLicenses, selfBindLicense, subscribePlugins, subscribeProxies, subscribeSelfLicenses } from 'xo'
import Proxies from './proxies'
import Xosan from './xosan'
import Xostor from './xostor'
// -----------------------------------------------------------------------------
@@ -196,7 +196,7 @@ export default class Licenses extends Component {
return getLicenses()
.then(licenses => {
const { proxy, xcpng, xoa, xosan } = groupBy(licenses, license => {
const { proxy, xcpng, xoa, xosan, xostor } = groupBy(licenses, license => {
for (const productType of license.productTypes) {
if (productType === 'xo') {
return 'xoa'
@@ -210,6 +210,9 @@ export default class Licenses extends Component {
if (productType === 'xcpng') {
return 'xcpng'
}
if (productType === 'xostor') {
return 'xostor'
}
}
return 'other'
})
@@ -219,6 +222,7 @@ export default class Licenses extends Component {
xcpng,
xoa,
xosan,
xostor,
},
})
})
@@ -300,6 +304,21 @@ export default class Licenses extends Component {
}
})
// --- XOSTOR ---
forEach(licenses.xostor, 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: 'XOSTOR',
type: 'xostor',
srId: license.boundObjectId,
})
}
})
return products
}
)
@@ -377,18 +396,8 @@ export default class Licenses extends Component {
</Row>
<Row>
<Col>
<h2>
XOSAN
<a
className='btn btn-secondary ml-1'
href='https://xen-orchestra.com/#!/xosan-home'
target='_blank'
rel='noopener noreferrer'
>
<Icon icon='bug' /> {_('productSupport')}
</a>
</h2>
<Xosan xosanLicenses={this.state.licenses.xosan} updateLicenses={this._updateLicenses} />
<h2>{_('xostor')}</h2>
<Xostor xostorLicenses={this.state.licenses.xostor} updateLicenses={this._updateLicenses} />
</Col>
</Row>
<Row>

View File

@@ -1,134 +0,0 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import Link from 'link'
import React from 'react'
import renderXoItem, { Pool } from 'render-xo-item'
import SelectLicense from 'select-license'
import SortedTable from 'sorted-table'
import { connectStore } from 'utils'
import { createSelector, createGetObjectsOfType } from 'selectors'
import { filter, forEach, includes, map } from 'lodash'
import { unlockXosan } from 'xo'
class XosanLicensesForm extends Component {
state = {
licenseId: 'none',
}
onChangeLicense = event => {
this.setState({ licenseId: event.target.value })
}
unlockXosan = () => {
const { item, userData } = this.props
return unlockXosan(this.state.licenseId, item.id).then(userData.updateLicenses)
}
render() {
const { item, userData } = this.props
const { licenseId } = this.state
const license = userData.licensesByXosan[item.id]
if (license === null) {
return (
<span className='text-danger'>
{_('xosanMultipleLicenses')} <a href='https://xen-orchestra.com/'>{_('contactUs')}</a>
</span>
)
}
return license?.productId === 'xosan' ? (
<span>{license.id.slice(-4)}</span>
) : (
<form className='form-inline'>
<SelectLicense onChange={this.onChangeLicense} productType='xosan' />
<ActionButton
btnStyle='primary'
className='ml-1'
disabled={licenseId === 'none'}
handler={this.unlockXosan}
handlerParam={licenseId}
icon='connect'
>
{_('bindLicense')}
</ActionButton>
</form>
)
}
}
const XOSAN_COLUMNS = [
{
name: _('xosanName'),
itemRenderer: sr => <Link to={`srs/${sr.id}`}>{renderXoItem(sr)}</Link>,
sortCriteria: 'name_label',
},
{
name: _('xosanPool'),
itemRenderer: sr => <Pool id={sr.$pool} link />,
},
{
name: _('license'),
component: XosanLicensesForm,
},
]
const XOSAN_INDIVIDUAL_ACTIONS = [
{
label: _('productSupport'),
icon: 'support',
handler: () => window.open('https://xen-orchestra.com'),
},
]
@connectStore(() => ({
xosanSrs: createGetObjectsOfType('SR').filter([
({ SR_type }) => SR_type === 'xosan', // eslint-disable-line camelcase
]),
}))
export default class Xosan extends Component {
_getLicensesByXosan = createSelector(
() => this.props.xosanLicenses,
licenses => {
const licensesByXosan = {}
forEach(licenses, license => {
let xosanId
if ((xosanId = license.boundObjectId) === undefined) {
return
}
licensesByXosan[xosanId] =
licensesByXosan[xosanId] !== undefined
? null // XOSAN bound to multiple licenses!
: license
})
return licensesByXosan
}
)
_getKnownXosans = createSelector(
createSelector(
() => this.props.xosanLicenses,
(licenses = []) => filter(map(licenses, 'boundObjectId'))
),
() => this.props.xosanSrs,
(knownXosanIds, xosanSrs) => filter(xosanSrs, ({ id }) => includes(knownXosanIds, id))
)
render() {
return (
<SortedTable
collection={this._getKnownXosans()}
columns={XOSAN_COLUMNS}
individualActions={XOSAN_INDIVIDUAL_ACTIONS}
stateUrlParam='s_xosan'
userData={{
licensesByXosan: this._getLicensesByXosan(),
xosanSrs: this.props.xosanSrs,
updateLicenses: this.props.updateLicenses,
}}
/>
)
}
}

View File

@@ -0,0 +1,104 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import decorate from 'apply-decorators'
import Icon from 'icon'
import React from 'react'
import SelectLicense from 'select-license'
import SortedTable from 'sorted-table'
import Tooltip from 'tooltip'
import { bindLicense } from 'xo'
import { connectStore } from 'utils'
import { createGetObjectsOfType } from 'selectors'
import { groupBy } from 'lodash'
import { injectState, provideState } from 'reaclette'
import { Pool, Sr } from 'render-xo-item'
class XostorLicensesForm extends Component {
state = {
licenseId: 'none',
}
bind = () => {
const { item, userData } = this.props
return bindLicense(this.state.licenseId, item.uuid).then(userData.updateLicenses)
}
render() {
const { item, userData } = this.props
const { licenseId } = this.state
const licenses = userData.licensesByXostorUuid[item.id]
// Xostor bound to multiple licenses
if (licenses?.length > 1) {
return (
<div>
<span>{licenses.map(license => license.id.slice(-4)).join(',')}</span>{' '}
<Tooltip content={_('xostorMultipleLicenses')}>
<Icon color='text-danger' icon='alarm' />
</Tooltip>
</div>
)
}
const license = licenses?.[0]
return license !== undefined ? (
<span>{license.id.slice(-4)}</span>
) : (
<form className='form-inline'>
<SelectLicense onChange={this.linkState('licenseId')} productType='xostor' />
<ActionButton
btnStyle='primary'
className='ml-1'
disabled={licenseId === 'none'}
handler={this.bind}
handlerParam={licenseId}
icon='connect'
>
{_('bindLicense')}
</ActionButton>
</form>
)
}
}
const INDIVIDUAL_ACTIONS = [
{
label: _('productSupport'),
icon: 'support',
handler: () => window.open('https://xen-orchestra.com'),
},
]
const COLUMNS = [
{
default: true,
name: _('name'),
itemRenderer: sr => <Sr id={sr.id} link container={false} />,
sortCriteria: 'name_label',
},
{ name: _('pool'), itemRenderer: sr => <Pool id={sr.$pool} link /> },
{ name: _('license'), component: XostorLicensesForm },
]
const Xostor = decorate([
connectStore(() => ({
xostorSrs: createGetObjectsOfType('SR').filter([({ SR_type }) => SR_type === 'linstor']),
})),
provideState({
computed: {
licensesByXostorUuid: (state, { xostorLicenses }) => groupBy(xostorLicenses, 'boundObjectId'),
},
}),
injectState,
({ state, xostorSrs, updateLicenses }) => (
<SortedTable
collection={xostorSrs}
columns={COLUMNS}
data-updateLicenses={updateLicenses}
data-licensesByXostorUuid={state.licensesByXostorUuid}
individualActions={INDIVIDUAL_ACTIONS}
/>
),
])
export default Xostor

View File

@@ -0,0 +1,4 @@
.disksSelectors {
display: flex;
align-items: flex-end;
}

View File

@@ -0,0 +1,71 @@
import _ from 'intl'
import ActionButton from 'action-button'
import decorate from 'apply-decorators'
import Icon from 'icon'
import React from 'react'
import { injectState, provideState } from 'reaclette'
import { Container, Col, Row } from 'grid'
import { TryXoa } from 'utils'
import { getXoaPlan, SOURCES } from 'xoa-plans'
import NewXostorForm from './new-xostor-form'
import XostorList from './xostor-list'
import Page from '../page'
const HEADER = (
<Container>
<h2>
<Icon icon='menu-xostor' /> {_('xostor')}
</h2>
</Container>
)
const Xostor = decorate([
provideState({
initialState: () => ({ showNewXostorForm: false }),
effects: {
_toggleShowNewXostorForm() {
this.state.showNewXostorForm = !this.state.showNewXostorForm
},
},
}),
injectState,
({ effects, state }) => (
<Page header={HEADER}>
{getXoaPlan() === SOURCES ? (
<Container>
<h2 className='text-info'>{_('xostorAvailableInXoa')}</h2>
<p>
<TryXoa page='xosan' />
</p>
</Container>
) : (
<Container>
<div className='alert alert-warning'>
<p className='mb-0'>
<strong>
<Icon icon='alarm' /> {_('xostorIsInBetaStage')}
</strong>
</p>
</div>
<XostorList />
<Row className='mb-1'>
<Col>
<ActionButton
btnStyle='primary'
handler={effects._toggleShowNewXostorForm}
icon={state.showNewXostorForm ? 'minus' : 'plus'}
>
{_('new')}
</ActionButton>
</Col>
</Row>
{state.showNewXostorForm && <NewXostorForm />}
</Container>
)}
</Page>
),
])
export default Xostor

View File

@@ -0,0 +1,593 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Collapse from 'collapse'
import decorate from 'apply-decorators'
import Icon from 'icon'
import React from 'react'
import Select from 'form/select'
import semver from 'semver'
import { Card, CardBlock, CardHeader } from 'card'
import { connectStore, formatSize } from 'utils'
import { Container, Col, Row } from 'grid'
import { createGetObjectsOfType } from 'selectors'
import { find, first, map, mapValues, remove, size, some } from 'lodash'
import { createXostorSr, getBlockdevices } from 'xo'
import { injectState, provideState } from 'reaclette'
import { Input as DebounceInput } from 'debounce-input-decorator'
import { Pool as PoolRenderItem, Network as NetworkRenderItem } from 'render-xo-item'
import { SelectHost, SelectPool, SelectNetwork } from 'select-objects'
import { toggleState, linkState } from 'reaclette-utils'
import styles from './index.css'
const MINIMAL_POOL_VERSION_FOR_XOSTOR = '8.2.1'
const N_HOSTS_MIN = 3
const N_HOSTS_MAX = 7
const PROVISIONING_OPTIONS = [
{ value: 'thin', label: _('thin') },
{ value: 'thick', label: _('thick') },
]
const REPLICATION_OPTIONS = [
{ value: 1, label: '1' },
{ value: 2, label: '2' },
{ value: 3, label: '3' },
]
const hasXostor = srs => some(srs, sr => sr.SR_type === 'linstor')
const formatDiskName = name => '/dev/' + name
const diskHasChildren = disk => Array.isArray(disk.children) && disk.children.length > 0
const isDiskRecommendedType = disk => disk.type === 'disk' || disk.type.startsWith('raid')
const isDiskMounted = disk => disk.mountpoint !== ''
const isDiskRo = disk => disk.ro === '1'
const isTapdevsDisk = disk => disk.name.startsWith('td')
const isWithinRecommendedHostRange = hosts => size(hosts) >= N_HOSTS_MIN && size(hosts) <= N_HOSTS_MAX
const isXcpngHost = host => host?.productBrand === 'XCP-ng'
const isHostRecentEnough = host => semver.satisfies(host?.version, `>=${MINIMAL_POOL_VERSION_FOR_XOSTOR}`)
const diskSelectRenderer = disk => (
<span>
<Icon icon='disk' /> {formatDiskName(disk.name)} {formatSize(Number(disk.size))}
</span>
)
const xostorDiskPredicate = disk =>
isDiskRecommendedType(disk) &&
!isDiskRo(disk) &&
!isDiskMounted(disk) &&
!diskHasChildren(disk) &&
!isTapdevsDisk(disk)
// ===================================================================
const StorageCard = decorate([
injectState,
({ effects, state }) => (
<Card>
<CardHeader>{_('storage')}</CardHeader>
<CardBlock>
<Row>
<Col>
{_('name')}
<DebounceInput className='form-control' name='srName' onChange={effects.linkState} value={state.srName} />
</Col>
</Row>
<Row className='mt-1'>
<Col>
{_('description')}
<DebounceInput
className='form-control'
name='srDescription'
onChange={effects.linkState}
value={state.srDescription}
/>
</Col>
</Row>
</CardBlock>
</Card>
),
])
const SettingsCard = decorate([
provideState({
computed: {
showWarningReplication: state => state.replication?.value === 1,
},
}),
injectState,
({ effects, state }) => (
<Card>
<CardHeader>{_('settings')}</CardHeader>
<CardBlock>
<Row>
<Col>
{_('replication')}
<Select options={REPLICATION_OPTIONS} onChange={effects.onReplicationChange} value={state.replication} />
{state.showWarningReplication && (
<p className='text-warning'>
<Icon icon='alarm' /> {_('xostorReplicationWarning')}
</p>
)}
</Col>
</Row>
<Row className='form-group mt-1'>
<Col>
{_('provisioning')}
<Select onChange={effects.onProvisioningChange} options={PROVISIONING_OPTIONS} value={state.provisioning} />
</Col>
</Row>
</CardBlock>
</Card>
),
])
const PoolCard = decorate([
connectStore({
srs: createGetObjectsOfType('SR').groupBy('$pool'),
}),
provideState({
initialState: () => ({ onlyShowXostorPools: true }),
effects: {
toggleState,
},
computed: {
poolPredicate: (state, props) => {
if (!state.onlyShowXostorPools) {
return undefined
}
return pool => {
const poolHosts = props.hostsByPoolId?.[pool.id]
const host = first(poolHosts)
return (
isWithinRecommendedHostRange(poolHosts) &&
isXcpngHost(host) &&
!hasXostor(props.srs[pool.id]) &&
isHostRecentEnough(host)
)
}
},
poolIsWithinRecommendedHostRange: state => isWithinRecommendedHostRange(state.poolHosts),
poolHasXostor: (state, props) => hasXostor(props.srs[state.poolId]),
isPoolRecentEnough: state => isHostRecentEnough(first(state.poolHosts)),
isPoolXostorCompatible: state =>
state.isXcpngHost && state.poolIsWithinRecommendedHostRange && !state.poolHasXostor && state.isPoolRecentEnough,
},
}),
injectState,
({ effects, state }) => (
<Card>
<CardHeader>{_('pool')}</CardHeader>
<CardBlock>
<div>
<label>
<input
checked={state.onlyShowXostorPools}
name='onlyShowXostorPools'
onChange={effects.toggleState}
type='checkbox'
/>{' '}
{_('onlyShowXostorRequirements', { type: _('pools') })}
</label>
<SelectPool onChange={effects.onPoolChange} predicate={state.poolPredicate} value={state.poolId} />
{state.poolHosts !== undefined && !state.isPoolXostorCompatible && (
<div className='text-danger'>
{/* FIXME: add link of the documentation when ready */}
<a href='#' rel='noreferrer' target='_blank'>
{_('objectDoesNotMeetXostorRequirements', { object: <PoolRenderItem id={state.poolId} /> })}
</a>
<ul>
{!state.isXcpngHost && <li>{_('notXcpPool')}</li>}
{!state.poolIsWithinRecommendedHostRange && <li>{_('wrongNumberOfHosts')}</li>}
{state.poolHasXostor && <li>{_('poolAlreadyHasXostor')}</li>}
{!state.isPoolRecentEnough && (
<li>{_('poolNotRecentEnough', { version: first(state.poolHosts).version })}</li>
)}
</ul>
</div>
)}
<em>
<Icon icon='info' /> {_('xostorPackagesWillBeInstalled')}
</em>
</div>
</CardBlock>
</Card>
),
])
const NetworkCard = decorate([
provideState({
initialState: () => ({ onlyShowXostorNetworks: true }),
effects: {
toggleState,
},
computed: {
networksPredicate: (state, props) => network => {
const isOnPool = network.$pool === state.poolId
const pifs = network.PIFs
return state.onlyShowXostorNetworks
? isOnPool && pifs.length > 0 && pifs.every(pifId => props.pifs[pifId].ip !== '')
: isOnPool
},
},
}),
injectState,
({ effects, state }) => (
<Card>
<CardHeader>{_('network')}</CardHeader>
<CardBlock>
<label>
<input
checked={state.onlyShowXostorNetworks}
name='onlyShowXostorNetworks'
onChange={effects.toggleState}
type='checkbox'
/>{' '}
{_('onlyShowXostorRequirements', { type: _('networks') })}
</label>
<SelectNetwork
disabled={!state.isPoolSelected}
onChange={effects.onNetworkChange}
predicate={state.networksPredicate}
value={state.networkId}
/>
</CardBlock>
</Card>
),
])
const DisksCard = decorate([
provideState({
initialState: () => ({
onlyShowXostorDisks: true,
}),
effects: {
toggleState,
_onDiskChange(_, disk) {
this.effects.onDiskChange(disk, this.state.hostId)
},
},
computed: {
_blockdevices: async state =>
state.isHostSelected && state.isXcpngHost ? (await getBlockdevices(state.hostId)).blockdevices : undefined,
_disks: state =>
state.onlyShowXostorDisks ? state._blockdevices?.filter(xostorDiskPredicate) : state._blockdevices,
predicate: state => host => host.$pool === state.poolId,
isHostSelected: state => state.hostId !== undefined,
selectableDisks: state =>
state._disks
?.filter(disk => !state.disksByHost[state.hostId]?.some(_disk => _disk.name === disk.name))
.sort((prev, next) => Number(next.size) - Number(prev.size)),
},
}),
injectState,
({ effects, state }) => (
<Card>
<CardHeader>{_('disks')}</CardHeader>
<CardBlock>
<Row>
<Col size={8}>
<Row className={styles.disksSelectors}>
<Col size={6}>
<SelectHost
disabled={!state.isPoolSelected}
onChange={effects.onHostChange}
predicate={state.predicate}
value={state.hostId}
/>
</Col>
<Col size={6}>
<label>
<input
checked={state.onlyShowXostorDisks}
onChange={effects.toggleState}
name='onlyShowXostorDisks'
type='checkbox'
/>{' '}
{_('onlyShowXostorRequirements', { type: _('disks') })}
</label>
{state.isPoolSelected && !state.isXcpngHost && (
<p className='text-danger mb-0'>
<Icon icon='alarm' /> {_('cantFetchDisksFromNonXcpngHost')}
</p>
)}
<Select
disabled={!state.isHostSelected || !state.isPoolSelected || !state.isXcpngHost}
onChange={effects._onDiskChange}
optionRenderer={diskSelectRenderer}
options={state.isHostSelected ? state.selectableDisks : []}
placeholder={_('selectDisks')}
value={null}
/>
</Col>
</Row>
<Row className='mt-1'>
<Col>
<SelectedDisks hostId={state.hostId} />
</Col>
</Row>
</Col>
<Col size={4}>
{map(state.poolHosts, host => (
<Collapse
buttonText={_('xostorDisksDropdownLabel', {
nDisks: state.disksByHost[host.id]?.length ?? 0,
hostname: host.hostname,
})}
defaultOpen
key={host.id}
size='small'
>
<SelectedDisks hostId={host.id} fromDropdown />
</Collapse>
))}
</Col>
</Row>
</CardBlock>
</Card>
),
])
const SelectedDisks = decorate([
provideState({
effects: {
_onDiskRemove(_, disk) {
this.effects.onDiskRemove(disk, this.props.hostId)
},
},
computed: {
disksHost: (state, props) => state.disksByHost[props.hostId],
},
}),
injectState,
({ effects, state, fromDropdown }) =>
state.isHostSelected || fromDropdown ? (
state.disksHost === undefined || state.disksHost.length < 1 ? (
<p>{_('noDisks')}</p>
) : (
<ul className='list-group'>
{state.disksHost.map(disk => (
<ItemSelectedDisks disk={disk} key={disk.name} onDiskRemove={effects._onDiskRemove} />
))}
</ul>
)
) : null,
])
const ItemSelectedDisks = ({ disk, onDiskRemove }) => {
const _isDiskRecommendedType = isDiskRecommendedType(disk)
const _isDiskRo = isDiskRo(disk)
const _isDiskMounted = isDiskMounted(disk)
const _diskHasChildren = diskHasChildren(disk)
const _isTapdevsDisk = isTapdevsDisk(disk)
const isDiskValid = _isDiskRecommendedType && !_isDiskRo && !_isDiskMounted && !_diskHasChildren && !_isTapdevsDisk
return (
<li className='list-group-item'>
<Icon icon='disk' /> {formatDiskName(disk.name)} {formatSize(Number(disk.size))}
<ActionButton
btnStyle='danger'
className='pull-right'
handler={onDiskRemove}
handlerParam={disk}
icon='delete'
size='small'
/>
{!isDiskValid && (
<div className='text-danger'>
<Icon icon='error' /> {_('diskIncompatibleXostor')}
<ul>
{!_isDiskRecommendedType && <li>{_('selectedDiskTypeIncompatibleXostor', { type: disk.type })}</li>}
{_isDiskRo && <li>{_('diskIsReadOnly')}</li>}
{_isDiskMounted && <li>{_('diskAlreadyMounted', { mountpoint: disk.mountpoint })}</li>}
{_diskHasChildren && <li>{_('diskHasChildren')}</li>}
{_isTapdevsDisk && <li>{_('isTapdevsDisk')}</li>}
</ul>
</div>
)}
</li>
)
}
const SummaryCard = decorate([
provideState({
computed: {
areHostsDisksConsistent: state =>
state._disksByHostValues.every(disks => disks.length === state._disksByHostValues[0]?.length),
finalSize: state => {
const totalSize = state._disksByHostValues.reduce((minSize, disks) => {
const size = disks.reduce((acc, disk) => acc + Number(disk.size), 0)
return minSize === 0 || size < minSize ? size : minSize
}, 0)
return (totalSize * state.numberOfHostsWithDisks) / state.replication.value
},
},
}),
injectState,
({ state }) => {
const srDescription = state.srDescription.trim()
return (
<Card>
<CardHeader>{_('summary')}</CardHeader>
<CardBlock>
{state.isFormInvalid ? (
<div className='text-danger'>
<p>{_('fieldsMissing')}</p>
<ul>
{state.isReplicationMissing && <li>{_('fieldRequired', { field: _('replication') })}</li>}
{state.isProvisioningMissing && <li>{_('fieldRequired', { field: _('provisioning') })}</li>}
{state.isNameMissing && <li>{_('fieldRequired', { field: _('name') })}</li>}
{state.isDisksMissing && <li>{_('xostorDiskRequired')}</li>}
</ul>
</div>
) : (
<div>
{!state.areHostsDisksConsistent && (
<p className='text-warning'>
<Icon icon='alarm' /> {_('hostsNotSameNumberOfDisks')}
</p>
)}
<Row>
<Col size={6}>{_('keyValue', { key: _('name'), value: state.srName })}</Col>
<Col size={6}>
{_('keyValue', {
key: _('description'),
value: srDescription === '' ? _('noValue') : srDescription,
})}
</Col>
</Row>
<Row>
<Col size={6}>{_('keyValue', { key: _('replication'), value: state.replication.label })}</Col>
<Col size={6}>{_('keyValue', { key: _('provisioning'), value: state.provisioning.label })}</Col>
</Row>
<Row>
<Col size={12}>{_('keyValue', { key: _('pool'), value: <PoolRenderItem id={state.poolId} /> })}</Col>
{/* FIXME: XOSTOR network management is not yet implemented at XOSTOR level */}
{/* <Col size={6}>
{_('keyValue', { key: _('network'), value: <NetworkRenderItem id={state.networkId} /> })}
</Col> */}
</Row>
<Row>
<Col size={6}>{_('keyValue', { key: _('numberOfHosts'), value: state.numberOfHostsWithDisks })}</Col>
<Col size={6}>
{_('keyValue', { key: _('approximateFinalSize'), value: formatSize(state.finalSize) })}
</Col>
</Row>
</div>
)}
</CardBlock>
</Card>
)
},
])
const NewXostorForm = decorate([
connectStore({
hostsByPoolId: createGetObjectsOfType('host').sort().groupBy('$pool'),
networks: createGetObjectsOfType('network'),
pifs: createGetObjectsOfType('PIF'),
}),
provideState({
initialState: () => ({
_networkId: undefined,
_createdSrUuid: undefined, // used for redirection when the storage has been created
disksByHost: {},
provisioning: PROVISIONING_OPTIONS[0], // default value 'thin'
poolId: undefined,
hostId: undefined,
replication: REPLICATION_OPTIONS[1], // default value 2
srDescription: '',
srName: '',
}),
effects: {
linkState,
onHostChange(_, host) {
this.state.hostId = host?.id
},
onPoolChange(_, pool) {
this.state.disksByHost = {}
this.state.poolId = pool?.id
},
onReplicationChange(_, replication) {
this.state.replication = replication
},
onProvisioningChange(_, provisioning) {
this.state.provisioning = provisioning
},
onNetworkChange(_, network) {
this.state._networkId = network?.id ?? null
},
onDiskChange(_, disk, hostId) {
const { disksByHost } = this.state
if (disksByHost[hostId] === undefined) {
disksByHost[hostId] = []
}
disksByHost[hostId].push(disk)
this.state.disksByHost = { ...disksByHost }
},
onDiskRemove(_, disk, hostId) {
const disks = this.state.disksByHost[hostId]
remove(disks, _disk => _disk.name === disk.name)
this.state.disksByHost = {
...this.state.disksByHost,
[hostId]: disks,
}
},
async createXostorSr() {
const { disksByHost, srDescription, srName, provisioning, replication } = this.state
this.state._createdSrUuid = await createXostorSr({
description: srDescription.trim() === '' ? undefined : srDescription.trim(),
disksByHost: mapValues(disksByHost, disks => disks.map(disk => formatDiskName(disk.name))),
name: srName.trim() === '' ? undefined : srName.trim(),
provisioning: provisioning.value,
replication: replication.value,
})
},
},
computed: {
// Private ==========
_disksByHostValues: state => Object.values(state.disksByHost).filter(disks => disks.length > 0),
_defaultNetworkId: (state, props) => props.networks?.[state._pifManagement?.$network]?.id,
_pifManagement: (state, props) => find(props.pifs, pif => pif.$pool === state.poolId && pif.management),
// Utils ============
poolHosts: (state, props) => props.hostsByPoolId?.[state.poolId],
isPoolSelected: state => state.poolId !== undefined,
numberOfHostsWithDisks: state => state._disksByHostValues.length,
isReplicationMissing: state => state.replication === null,
isProvisioningMissing: state => state.provisioning === null,
isNameMissing: state => state.srName.trim() === '',
isDisksMissing: state => state.numberOfHostsWithDisks === 0,
isFormInvalid: state =>
state.isReplicationMissing || state.isProvisioningMissing || state.isNameMissing || state.isDisksMissing,
isXcpngHost: state => isXcpngHost(first(state.poolHosts)),
getSrPath: state => () => `/srs/${state._createdSrUuid}`,
// State ============
networkId: state => (state._networkId === undefined ? state._defaultNetworkId : state._networkId),
},
}),
injectState,
({ effects, resetState, state, hostsByPoolId, networks, pifs }) => (
<Container>
<Row>
<Col size={6}>
<StorageCard />
</Col>
<Col size={6}>
<SettingsCard />
</Col>
</Row>
<Row>
<Col size={12}>
<PoolCard hostsByPoolId={hostsByPoolId} />
</Col>
{/* FIXME: XOSTOR network management is not yet implemented at XOSTOR level */}
{/* <Col size={6}>
<NetworkCard networks={networks} pifs={pifs} />
</Col> */}
</Row>
<Row>
<DisksCard />
</Row>
<Row>
<SummaryCard />
</Row>
<Row>
<ActionButton
btnStyle='primary'
disabled={state.isFormInvalid}
handler={effects.createXostorSr}
icon='add'
redirectOnSuccess={state.getSrPath}
>
{_('create')}
</ActionButton>
<ActionButton className='ml-1' handler={resetState} icon='reset'>
{_('formReset')}
</ActionButton>
</Row>
</Container>
),
])
export default NewXostorForm

View File

@@ -0,0 +1,79 @@
import _ from 'intl'
import decorate from 'apply-decorators'
import React from 'react'
import SortedTable from 'sorted-table'
import Tooltip from 'tooltip'
import { connectStore, formatSize } from 'utils'
import { createGetObjectsOfType, createSelector } from 'selectors'
import { deleteSr } from 'xo'
import { map } from 'lodash'
import { Pool } from 'render-xo-item'
const COLUMNS = [
{
name: _('srPool'),
itemRenderer: sr => <Pool id={sr.pool.id} link />,
sortCriteria: 'pool.name_label',
},
{
name: _('name'),
itemRenderer: sr => sr.name_label,
sortCriteria: 'name_label',
},
{
name: _('provisioning'),
itemRenderer: sr => sr.allocationStrategy,
sortCriteria: 'allocationStrategy',
},
{
name: _('size'),
itemRenderer: sr => formatSize(sr.size),
sortCriteria: 'size',
},
{
name: _('usedSpace'),
itemRenderer: sr => {
const used = (sr.physical_usage * 100) / sr.size
return (
<Tooltip
content={_('spaceLeftTooltip', {
used: String(Math.round(used)),
free: formatSize(sr.size - sr.physical_usage),
})}
>
<progress className='progress' max='100' value={used} />
</Tooltip>
)
},
sortCriteria: sr => (sr.physical_usage * 100) / sr.size,
},
]
const INDIVIDUAL_ACTIONS = [
{
handler: deleteSr,
icon: 'delete',
label: _('delete'),
level: 'danger',
},
]
const XostorList = decorate([
connectStore(() => ({
xostorSrs: createSelector(
createGetObjectsOfType('SR').filter([sr => sr.SR_type === 'linstor']),
createGetObjectsOfType('pool').groupBy('id'),
(srs, poolByIds) => {
return map(srs, sr => ({
...sr,
pool: poolByIds[sr.$pool][0],
}))
}
),
})),
({ xostorSrs }) => (
<SortedTable collection={xostorSrs} columns={COLUMNS} individualActions={INDIVIDUAL_ACTIONS} stateUrlParam='s' />
),
])
export default XostorList