diff --git a/pkg/harvester/config/feature-flags.js b/pkg/harvester/config/feature-flags.js index 5f0f182a..676c8f9b 100644 --- a/pkg/harvester/config/feature-flags.js +++ b/pkg/harvester/config/feature-flags.js @@ -76,6 +76,7 @@ const FEATURE_FLAGS = { 'longhornV2HugepageSettings', 'staticIPForVM', 'fsFreezeDeadline', + 'highPerformanceStorage', ], 'v1.9.1': [ 'lvmDmThinOnly', diff --git a/pkg/harvester/config/harvester-map.js b/pkg/harvester/config/harvester-map.js index 172ef6ac..e21d38aa 100644 --- a/pkg/harvester/config/harvester-map.js +++ b/pkg/harvester/config/harvester-map.js @@ -39,6 +39,21 @@ export const VOLUME_TYPE = [{ value: 'cd-rom' }]; +// KubeVirt high-performance disk features +// https://kubevirt.io/user-guide/storage/disks_and_volumes/#high-performance-features +export const DISK_CACHE_MODE = ['', 'none', 'writeback', 'writethrough']; + +export const DISK_IO_MODE = ['', 'native', 'threads']; + +export const IO_THREADS_POLICY = ['', 'shared', 'auto', 'supplementalPool']; + +// "Easy-to-consume" presets layered on top of the raw KubeVirt fields. +export const DISK_PERFORMANCE_PROFILE = { + DEFAULT: 'default', + HIGH: 'highPerformance', + CUSTOM: 'custom', +}; + export const VOLUME_HOTPLUG_ACTION = { INSERT_CDROM_IMAGE: 'INSERT_CDROM_IMAGE', EJECT_CDROM_IMAGE: 'EJECT_CDROM_IMAGE', diff --git a/pkg/harvester/config/labels-annotations.js b/pkg/harvester/config/labels-annotations.js index 7a4f0ab9..40f1623c 100644 --- a/pkg/harvester/config/labels-annotations.js +++ b/pkg/harvester/config/labels-annotations.js @@ -33,6 +33,11 @@ export const HCI = { CLONE_BACKEND_STORAGE_STATUS: 'harvesterhci.io/clone-backend-storage-status', MIGRATION_STATE: 'harvesterhci.io/migrationState', VOLUME_CLAIM_TEMPLATE: 'harvesterhci.io/volumeClaimTemplates', + // Default KubeVirt high-performance disk settings, applied when the volume is + // attached to a VM as a disk. See disks_and_volumes.md#high-performance-features. + DISK_CACHE_MODE: 'harvesterhci.io/disk-cache-mode', + DISK_IO_MODE: 'harvesterhci.io/disk-io-mode', + DISK_DEDICATED_IOTHREAD: 'harvesterhci.io/disk-dedicated-iothread', IMAGE_NAME: 'harvesterhci.io/image-name', INIT_IP: 'etcd.rke2.cattle.io/node-address', NODE_SCHEDULABLE: 'kubevirt.io/schedulable', diff --git a/pkg/harvester/detail/kubevirt.io.virtualmachine/index.vue b/pkg/harvester/detail/kubevirt.io.virtualmachine/index.vue index 645e4078..eedafd62 100644 --- a/pkg/harvester/detail/kubevirt.io.virtualmachine/index.vue +++ b/pkg/harvester/detail/kubevirt.io.virtualmachine/index.vue @@ -214,6 +214,14 @@ export default { this['diskRows'] = diskRows; this['networkRows'] = this.getNetworkRows(neu, { fromTemplate: false, init: false }); + + // getInitConfig() seeds these on created(); refresh them here too so the + // VM-wide performance panel does not go stale when the spec changes. + const domain = neu?.spec?.template?.spec?.domain; + + this['blockMultiQueue'] = domain?.devices?.blockMultiQueue || false; + this['ioThreadsPolicy'] = domain?.ioThreadsPolicy || ''; + this['ioThreadCount'] = domain?.ioThreads?.supplementalPoolThreadCount || 2; }, deep: true } @@ -254,6 +262,9 @@ export default { :namespace="value.metadata.namespace" :vm="value" :resource-type="value.type" + :block-multi-queue="blockMultiQueue" + :io-threads-policy="ioThreadsPolicy" + :io-thread-count="ioThreadCount" /> diff --git a/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue b/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue index bdded934..0358420b 100644 --- a/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue +++ b/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue @@ -308,6 +308,9 @@ export default { > + + + +import LabeledSelect from '@shell/components/form/LabeledSelect'; +import { Checkbox } from '@components/Form/Checkbox'; +import { Banner } from '@components/Banner'; +import { _VIEW } from '@shell/config/query-params'; +import { DISK_CACHE_MODE, DISK_IO_MODE, DISK_PERFORMANCE_PROFILE } from '../../../config/harvester-map'; + +const { DEFAULT, HIGH, CUSTOM } = DISK_PERFORMANCE_PROFILE; + +export default { + name: 'DiskPerformanceOptions', + + components: { + LabeledSelect, Checkbox, Banner + }, + + emits: ['update'], + + props: { + // The disk row. This component reads/writes value.cache, value.io, + // value.dedicatedIOThread and (for the "High Performance" preset) value.bus. + value: { + type: Object, + default: () => ({}) + }, + + mode: { + type: String, + default: 'create' + }, + }, + + data() { + const hasPerf = !!(this.value.cache || this.value.io || this.value.dedicatedIOThread); + + return { + DISK_PERFORMANCE_PROFILE, + expanded: hasPerf, + profile: this.detectProfile(), + // Bus the disk used before the "High Performance" preset switched it to + // virtio, so returning to "Default" can put it back. + busBeforeHighProfile: null, + }; + }, + + computed: { + isView() { + return this.mode === _VIEW; + }, + + storagePerformanceEnabled() { + return this.$store.getters['harvester-common/getFeatureEnabled']('highPerformanceStorage'); + }, + + isCdRom() { + return this.value.type === 'cd-rom'; + }, + + isCustom() { + return this.profile === CUSTOM; + }, + + isNativeIo() { + return this.value.io === 'native'; + }, + + profileOptions() { + return [DEFAULT, HIGH, CUSTOM].map((value) => ({ + label: this.t(`harvester.virtualMachine.volume.performance.profile.${ value }`), + value, + })); + }, + + cacheOptions() { + return DISK_CACHE_MODE.map((value) => ({ + label: this.cacheLabel(value), + value, + // Native AIO only works with an uncached (O_DIRECT) disk, so while it is + // selected the only valid cache option is "none" — disable everything + // else, including "Default", to prevent an invalid combination. + disabled: this.isNativeIo && value !== 'none', + })); + }, + + ioOptions() { + return DISK_IO_MODE.map((value) => ({ + label: value === '' ? this.t('harvester.virtualMachine.volume.performance.ioMode.default') : this.t(`harvester.virtualMachine.volume.performance.ioMode.${ value }`), + value, + })); + }, + + showFilesystemCacheWarning() { + return this.value.cache === 'none' && this.value.volumeMode === 'Filesystem'; + }, + + showBusTip() { + const hasPerf = !!(this.value.cache || this.value.io || this.value.dedicatedIOThread); + + return hasPerf && this.value.bus && this.value.bus !== 'virtio'; + }, + }, + + watch: { + // Keep the profile selector in sync if the row is repopulated (e.g. editing an existing VM). + 'value.cache'() { + this.profile = this.detectProfile(); + }, + 'value.io'() { + this.profile = this.detectProfile(); + }, + 'value.dedicatedIOThread'() { + this.profile = this.detectProfile(); + }, + }, + + methods: { + detectProfile() { + const { cache, io, dedicatedIOThread } = this.value; + + if (!cache && !io && !dedicatedIOThread) { + return DEFAULT; + } + + if (cache === 'none' && io === 'native' && dedicatedIOThread) { + return HIGH; + } + + return CUSTOM; + }, + + cacheLabel(value) { + if (value === '') { + return this.t('harvester.virtualMachine.volume.performance.cacheMode.default'); + } + + return this.t(`harvester.virtualMachine.volume.performance.cacheMode.${ value }`); + }, + + onProfileChange(profile) { + const previous = this.profile; + + this.profile = profile; + + if (profile === DEFAULT) { + this.value.cache = ''; + this.value.io = ''; + this.value.dedicatedIOThread = false; + + // "Default" means no overrides, so also undo the virtio bus that the + // "High Performance" preset applied. Skip it if the user has since + // picked a different bus themselves — that choice is theirs to keep. + if (this.busBeforeHighProfile !== null) { + if (this.value.bus === 'virtio') { + this.value.bus = this.busBeforeHighProfile; + } + this.busBeforeHighProfile = null; + } + } else if (profile === HIGH) { + this.value.cache = 'none'; + this.value.io = 'native'; + this.value.dedicatedIOThread = true; + + if (previous !== HIGH) { + this.busBeforeHighProfile = this.value.bus || ''; + } + this.value.bus = 'virtio'; + } + // CUSTOM keeps whatever is currently set, including the bus. + + this.update(); + }, + + onIoChange(io) { + this.value.io = io; + // Native AIO requires an uncached disk; force cache to "none" to keep the + // combo valid — including when cache is still on its "Default" ('') value, + // which libvirt would otherwise reject at domain start. + if (io === 'native' && this.value.cache !== 'none') { + this.value.cache = 'none'; + } + this.update(); + }, + + update() { + this.$emit('update'); + }, + }, +}; + + + + + diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/VMPerformanceOptions.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/VMPerformanceOptions.vue new file mode 100644 index 00000000..08491e4b --- /dev/null +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/VMPerformanceOptions.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/__tests__/DiskPerformanceOptions.test.ts b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/__tests__/DiskPerformanceOptions.test.ts new file mode 100644 index 00000000..59f20006 --- /dev/null +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/__tests__/DiskPerformanceOptions.test.ts @@ -0,0 +1,125 @@ +import { mount } from '@vue/test-utils'; +import { _EDIT } from '@shell/config/query-params'; +import DiskPerformanceOptions from '../DiskPerformanceOptions.vue'; + +const mountOptions = (value, { featureEnabled = true } = {}) => ({ + props: { value, mode: _EDIT }, + global: { + mocks: { + $store: { + getters: { + 'harvester-common/getFeatureEnabled': () => featureEnabled, + 'i18n/t': (key) => key, + 'i18n/exists': () => true, + } + }, + t: (key) => key, + }, + } +}); + +const hardDisk = (overrides = {}) => ({ + type: 'disk', bus: 'virtio', cache: '', io: '', dedicatedIOThread: false, ...overrides +}); + +describe('component: DiskPerformanceOptions', () => { + it('is hidden when the highPerformanceStorage feature is disabled', () => { + const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk(), { featureEnabled: false })); + + expect(wrapper.find('.disk-performance').exists()).toBe(false); + }); + + it('renders for a hard disk when the feature is enabled', () => { + const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk())); + + expect(wrapper.find('.disk-performance').exists()).toBe(true); + }); + + it('applies the High Performance preset and emits update', () => { + const value = hardDisk(); + const wrapper = mount(DiskPerformanceOptions, mountOptions(value)); + + wrapper.vm.onProfileChange('highPerformance'); + + expect(value.cache).toBe('none'); + expect(value.io).toBe('native'); + expect(value.dedicatedIOThread).toBe(true); + expect(value.bus).toBe('virtio'); + expect(wrapper.emitted('update')).toHaveLength(1); + }); + + it('clears all overrides when switching back to Default', () => { + const value = hardDisk({ + cache: 'none', io: 'native', dedicatedIOThread: true + }); + const wrapper = mount(DiskPerformanceOptions, mountOptions(value)); + + wrapper.vm.onProfileChange('default'); + + expect(value.cache).toBe(''); + expect(value.io).toBe(''); + expect(value.dedicatedIOThread).toBe(false); + expect(wrapper.emitted('update')).toHaveLength(1); + }); + + it('restores the original bus when leaving High Performance for Default', () => { + const value = hardDisk({ bus: 'sata' }); + const wrapper = mount(DiskPerformanceOptions, mountOptions(value)); + + wrapper.vm.onProfileChange('highPerformance'); + expect(value.bus).toBe('virtio'); + + wrapper.vm.onProfileChange('default'); + expect(value.bus).toBe('sata'); + }); + + it('keeps a bus the user picked themselves after High Performance', () => { + const value = hardDisk({ bus: 'sata' }); + const wrapper = mount(DiskPerformanceOptions, mountOptions(value)); + + wrapper.vm.onProfileChange('highPerformance'); + value.bus = 'scsi'; + + wrapper.vm.onProfileChange('default'); + expect(value.bus).toBe('scsi'); + }); + + it('leaves the bus alone for a disk that never used the High Performance preset', () => { + const value = hardDisk({ + bus: 'sata', cache: 'writeback', io: 'threads' + }); + const wrapper = mount(DiskPerformanceOptions, mountOptions(value)); + + wrapper.vm.onProfileChange('default'); + expect(value.bus).toBe('sata'); + }); + + it.each([ + ['a cached state', 'writeback'], + ['the Default cache', ''], + ])('forces cache to none when Native I/O is selected from %s', (_label, cache) => { + const value = hardDisk({ cache }); + const wrapper = mount(DiskPerformanceOptions, mountOptions(value)); + + wrapper.vm.onIoChange('native'); + + expect(value.io).toBe('native'); + expect(value.cache).toBe('none'); + expect(wrapper.emitted('update')).toHaveLength(1); + }); + + it('disables every cache option except none while Native I/O is active', () => { + const wrapper = mount(DiskPerformanceOptions, mountOptions(hardDisk({ io: 'native' }))); + + const disabledByValue = wrapper.vm.cacheOptions.reduce((acc, opt) => { + acc[opt.value] = opt.disabled; + + return acc; + }, {}); + + expect(disabledByValue['none']).toBe(false); + expect(disabledByValue['']).toBe(true); + expect(disabledByValue['writeback']).toBe(true); + expect(disabledByValue['writethrough']).toBe(true); + }); +}); diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/index.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/index.vue index 7a250f26..8190f906 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/index.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/index.vue @@ -7,13 +7,14 @@ import UnitInput from '@shell/components/form/UnitInput'; import { LabeledInput } from '@components/Form/LabeledInput'; import LabeledSelect from '@shell/components/form/LabeledSelect'; import ModalWithCard from '@shell/components/ModalWithCard'; +import VMPerformanceOptions from './VMPerformanceOptions'; +import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map'; import { PVC, STORAGE_CLASS } from '@shell/config/types'; import { clone } from '@shell/utils/object'; import { ucFirst, randomStr } from '@shell/utils/string'; import { removeObject } from '@shell/utils/array'; import { _VIEW, _EDIT, _CREATE } from '@shell/config/query-params'; import { PLUGIN_DEVELOPER, DEV } from '@shell/store/prefs'; -import { VOLUME_HOTPLUG_ACTION, SOURCE_TYPE } from '../../../config/harvester-map'; import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../../config/harvester'; import { HCI } from '../../../types'; import { VOLUME_MODE, ACCESS_MODE } from '@pkg/harvester/config/types'; @@ -23,10 +24,10 @@ import { EMPTY_IMAGE } from '../../../utils/vm'; const { READ_WRITE_MANY } = ACCESS_MODE; export default { - emits: ['update:value'], + emits: ['update:value', 'update:blockMultiQueue', 'update:ioThreadsPolicy', 'update:ioThreadCount'], components: { - Banner, BadgeStateFormatter, VueDraggableNext, InfoBox, LabeledInput, UnitInput, LabeledSelect, ModalWithCard + Banner, BadgeStateFormatter, VueDraggableNext, InfoBox, LabeledInput, UnitInput, LabeledSelect, ModalWithCard, VMPerformanceOptions }, props: { @@ -77,6 +78,22 @@ export default { resourceType: { type: String, default: '' + }, + + // KubeVirt high-performance VM-level features + blockMultiQueue: { + type: Boolean, + default: false + }, + + ioThreadsPolicy: { + type: String, + default: '' + }, + + ioThreadCount: { + type: Number, + default: 2 } }, @@ -147,6 +164,14 @@ export default { isLHV2VolExpansionFeatureEnabled() { return this.$store.getters['harvester-common/getFeatureEnabled']('lhV2VolExpansion'); }, + + hasVirtioDisk() { + return this.rows.some((R) => R.type === 'disk' && R.bus === 'virtio'); + }, + + hasDedicatedIOThread() { + return this.rows.some((R) => R.dedicatedIOThread); + }, }, watch: { @@ -508,6 +533,18 @@ export default { + + + + diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/existing.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/existing.vue index 2678a13d..1838f72f 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/existing.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/existing.vue @@ -14,6 +14,7 @@ import { VOLUME_MODE } from '@pkg/harvester/config/types'; import { HCI } from '../../../../types'; import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map'; import { GIBIBYTE } from '../../../../utils/unit'; +import DiskPerformanceOptions from '../DiskPerformanceOptions'; export default { name: 'HarvesterEditExisting', @@ -21,7 +22,7 @@ export default { emits: ['update'], components: { - UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner, Checkbox + UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner, Checkbox, DiskPerformanceOptions }, props: { @@ -172,6 +173,14 @@ export default { this.value.storageClassName = pvcResource.spec.storageClassName; this.value.volumeMode = pvcResource.spec.volumeMode; this.value.shareable = false; + + // Adopt the volume's default high-performance profile (if any) as a starting point. + const ann = pvcResource.metadata?.annotations || {}; + + this.value.cache = ann[HCI_ANNOTATIONS.DISK_CACHE_MODE] || ''; + this.value.io = ann[HCI_ANNOTATIONS.DISK_IO_MODE] || ''; + this.value.dedicatedIOThread = ann[HCI_ANNOTATIONS.DISK_DEDICATED_IOTHREAD] === 'true'; + this.update(); }, @@ -379,5 +388,11 @@ export default { class="mb-20" :label="value.volumeBackups.error.message" /> + + diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/vmImage.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/vmImage.vue index b79dd542..ab72bd59 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/vmImage.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/vmImage.vue @@ -15,6 +15,7 @@ import LabelValue from '@shell/components/LabelValue'; import { ucFirst } from '@shell/utils/string'; import { GIBIBYTE } from '../../../../utils/unit'; import { EMPTY_IMAGE } from '../../../../utils/vm'; +import DiskPerformanceOptions from '../DiskPerformanceOptions'; export default { name: 'HarvesterEditVMImage', @@ -22,7 +23,7 @@ export default { emits: ['update'], components: { - UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner + UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner, DiskPerformanceOptions }, props: { @@ -482,5 +483,11 @@ export default { color="error" :label="t('harvester.virtualMachine.volume.vmImageVolumeTip', {diskSize: diskSize, imageVirtualSize: imageVirtualSize})" /> + + diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/volume.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/volume.vue index 39b19d35..d2bd802e 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/volume.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/volume.vue @@ -9,6 +9,7 @@ import { formatSi, parseSi } from '@shell/utils/units'; import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map'; import { _VIEW } from '@shell/config/query-params'; import LabelValue from '@shell/components/LabelValue'; +import DiskPerformanceOptions from '../DiskPerformanceOptions'; import { ucFirst } from '@shell/utils/string'; import { LVM_DRIVER } from '../../../../models/harvester/storage.k8s.io.storageclass'; import { DATA_ENGINE_V2 } from '../../../../models/harvester/persistentvolumeclaim'; @@ -24,7 +25,7 @@ export default { emits: ['update'], components: { - InputOrDisplay, Loading, LabeledInput, LabeledSelect, UnitInput, LabelValue + InputOrDisplay, Loading, LabeledInput, LabeledSelect, UnitInput, LabelValue, DiskPerformanceOptions }, props: { @@ -362,6 +363,11 @@ export default { +
vBackup.volumeName === DISK.name) || null; return { - id: randomStr(5), + id: randomStr(5), bootOrder, source, - name: DISK.name, + name: DISK.name, realName, bus, volumeName, container, accessMode, - size: `${ formatSize }${ GIBIBYTE }`, - volumeMode: volumeMode || this.customVolumeMode, + size: `${ formatSize }${ GIBIBYTE }`, + volumeMode: volumeMode || this.customVolumeMode, image, type, storageClassName, hotpluggable, - shareable: DISK.shareable || false, + cache: DISK?.cache || '', + io: DISK?.io || '', + dedicatedIOThread: DISK?.dedicatedIOThread || false, + shareable: DISK.shareable || false, volumeStatus, dataSource, namespace, @@ -973,9 +991,25 @@ export default { // `shareable` is an attachment-level opt-in, remove the field // inherited from the old spec when the disk row no longer requests it mergedDisks.forEach((disk) => { - if (disk.shareable && !disks.find((D) => D.name === disk.name)?.shareable) { + const parsed = disks.find((D) => D.name === disk.name); + + if (disk.shareable && !parsed?.shareable) { delete disk.shareable; } + + // High-performance fields are only serialized when requested (see + // parseDisk). When editing, mergeDeviceList would otherwise keep the + // previous spec's cache/io/dedicatedIOThread even after the row is + // switched back to "Default" or the dedicated I/O thread is unchecked, + // silently preserving stale values — strip them so the disk matches + // what the row actually requests. + if (parsed) { + ['cache', 'io', 'dedicatedIOThread'].forEach((field) => { + if (!parsed[field] && field in disk) { + delete disk[field]; + } + }); + } }); let spec = { @@ -1012,6 +1046,35 @@ export default { delete spec.template.spec.volumes; } + // KubeVirt high-performance VM-level features (I/O threads & block multi-queue). + const domainRef = spec.template.spec.domain; + const hasDedicatedIOThread = mergedDisks.some((D) => D.dedicatedIOThread); + // If any disk requests a dedicated I/O thread, KubeVirt requires a policy; + // default it to "shared" so the dedicated thread is honored. + let ioThreadsPolicy = this.ioThreadsPolicy; + + if (hasDedicatedIOThread && !ioThreadsPolicy) { + ioThreadsPolicy = 'shared'; + } + + if (ioThreadsPolicy) { + domainRef.ioThreadsPolicy = ioThreadsPolicy; + } else { + delete domainRef.ioThreadsPolicy; + } + + if (ioThreadsPolicy === 'supplementalPool') { + domainRef.ioThreads = { supplementalPoolThreadCount: Number(this.ioThreadCount) || 1 }; + } else { + delete domainRef.ioThreads; + } + + if (this.blockMultiQueue) { + domainRef.devices.blockMultiQueue = true; + } else { + delete domainRef.devices.blockMultiQueue; + } + if (this.resourceType === HCI.VM) { if (!this.isSingle) { spec = this.multiVMScheduler(spec); @@ -1259,6 +1322,19 @@ export default { if (R.type === HARD_DISK) { out.disk = { bus: R.bus }; + + // KubeVirt high-performance per-disk features. `cache`, `io` and + // `dedicatedIOThread` live at the Disk level (siblings of `disk`), not + // inside the DiskTarget. See disks_and_volumes.md#high-performance-features. + if (R.cache) { + out.cache = R.cache; + } + if (R.io) { + out.io = R.io; + } + if (R.dedicatedIOThread) { + out.dedicatedIOThread = true; + } } else if (R.type === CD_ROM) { out.cdrom = { bus: R.bus }; }