diff --git a/pkg/harvester/config/feature-flags.js b/pkg/harvester/config/feature-flags.js index fb0bdfe4..b0ecc98b 100644 --- a/pkg/harvester/config/feature-flags.js +++ b/pkg/harvester/config/feature-flags.js @@ -58,7 +58,9 @@ const FEATURE_FLAGS = { 'resumeUpgradePausedNode', ], 'v1.7.1': [], - 'v1.8.0': [] + 'v1.8.0': [ + 'hotplugCdRom', + ], }; const generateFeatureFlags = () => { diff --git a/pkg/harvester/config/harvester-map.js b/pkg/harvester/config/harvester-map.js index 00b1dbf9..3813e110 100644 --- a/pkg/harvester/config/harvester-map.js +++ b/pkg/harvester/config/harvester-map.js @@ -39,6 +39,12 @@ export const VOLUME_TYPE = [{ value: 'cd-rom' }]; +export const VOLUME_HOTPLUG_ACTION = { + INSERT_CDROM_IMAGE: 'INSERT_CDROM_IMAGE', + EJECT_CDROM_IMAGE: 'EJECT_CDROM_IMAGE', + DETACH_DISK: 'DETACH_DISK' +}; + export const ACCESS_CREDENTIALS = { RESET_PWD: 'userPassword', INJECT_SSH: 'sshPublicKey' diff --git a/pkg/harvester/dialog/HarvesterHotUnplug.vue b/pkg/harvester/dialog/HarvesterHotUnplug.vue index cf3f0ea9..5d18a177 100644 --- a/pkg/harvester/dialog/HarvesterHotUnplug.vue +++ b/pkg/harvester/dialog/HarvesterHotUnplug.vue @@ -5,6 +5,10 @@ import { Card } from '@components/Card'; import { Banner } from '@components/Banner'; import AsyncButton from '@shell/components/AsyncButton'; +const VOLUME = 'volume'; +const NETWORK = 'network'; +const CDROM = 'cdrom'; + export default { name: 'HarvesterHotUnplug', @@ -40,19 +44,37 @@ export default { }, isVolume() { - return this.modalData.type === 'volume'; + return this.modalData.type === VOLUME; }, titleKey() { - return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.title' : 'harvester.virtualMachine.hotUnplug.detachNIC.title'; + const keys = { + [VOLUME]: 'harvester.virtualMachine.hotUnplug.detachVolume.title', + [CDROM]: 'harvester.virtualMachine.hotUnplug.ejectCdRomVolume.title', + [NETWORK]: 'harvester.virtualMachine.hotUnplug.detachNIC.title', + }; + + return keys[this.modalData.type]; }, actionLabelKey() { - return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.actionLabel' : 'harvester.virtualMachine.hotUnplug.detachNIC.actionLabel'; + const keys = { + [VOLUME]: 'harvester.virtualMachine.hotUnplug.detachVolume.actionLabels', + [CDROM]: 'harvester.virtualMachine.hotUnplug.ejectCdRomVolume.actionLabels', + [NETWORK]: 'harvester.virtualMachine.hotUnplug.detachNIC.actionLabels', + }; + + return keys[this.modalData.type]; }, successMessageKey() { - return this.isVolume ? 'harvester.virtualMachine.hotUnplug.detachVolume.success' : 'harvester.virtualMachine.hotUnplug.detachNIC.success'; + const keys = { + [VOLUME]: 'harvester.virtualMachine.hotUnplug.detachVolume.success', + [CDROM]: 'harvester.virtualMachine.hotUnplug.ejectCdRomVolume.success', + [NETWORK]: 'harvester.virtualMachine.hotUnplug.detachNIC.success', + }; + + return keys[this.modalData.type]; } }, @@ -65,10 +87,12 @@ export default { try { let res; - if (this.isVolume) { + if (this.modalData.type === VOLUME) { res = await this.actionResource.doAction('removeVolume', { diskName: this.name }); - } else { + } else if (this.modalData.type === NETWORK) { res = await this.actionResource.doAction('removeNic', { interfaceName: this.name }); + } else { + res = await this.actionResource.doAction('ejectCdRomVolume', { deviceName: this.name }); } if (res._status === 200 || res._status === 204) { diff --git a/pkg/harvester/dialog/HarvesterInsertCdRomVolume.vue b/pkg/harvester/dialog/HarvesterInsertCdRomVolume.vue new file mode 100644 index 00000000..52f917ba --- /dev/null +++ b/pkg/harvester/dialog/HarvesterInsertCdRomVolume.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/index.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/index.vue index 13c0221c..fff8e797 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/index.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/index.vue @@ -13,11 +13,12 @@ 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 { SOURCE_TYPE } from '../../../config/harvester-map'; +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 } from '@pkg/harvester/config/types'; import { OFF } from '../../../models/kubevirt.io.virtualmachine'; +import { EMPTY_IMAGE } from '../../../utils/vm'; export default { emits: ['update:value'], @@ -117,6 +118,10 @@ export default { return this.mode === _CREATE; }, + isHotplugCdRomFeatureEnabled() { + return this.$store.getters['harvester-common/getFeatureEnabled']('hotplugCdRom'); + }, + defaultStorageClass() { const defaultStorage = this.$store.getters['harvester/all'](STORAGE_CLASS).find((sc) => sc.isDefault); @@ -146,7 +151,7 @@ export default { value: { handler(neu) { const rows = clone(neu).map((V) => { - if (!this.isCreate && V.source !== SOURCE_TYPE.CONTAINER && !V.newCreateId) { + if (!this.isCreate && V.source !== SOURCE_TYPE.CONTAINER && !V.newCreateId && V.image !== EMPTY_IMAGE) { V.to = { name: `${ HARVESTER_PRODUCT }-c-cluster-resource-namespace-id`, params: { @@ -217,8 +222,48 @@ export default { } }, - unplugVolume(volume) { - this.vm.unplugVolume(volume.name); + canDoVolumeHotplugAction(volume) { + if (!this.isHotplugCdRomFeatureEnabled && volume.type === 'cd-rom') { + return false; + } + + if (volume.hotpluggable) { + return true; + } + + return volume.type === 'cd-rom' && volume.bus === 'sata' && volume.image === EMPTY_IMAGE; + }, + + getVolumeHotplugAction(volume) { + if (volume.type === 'cd-rom' && volume.bus === 'sata') { + if (volume.image === EMPTY_IMAGE) { + return VOLUME_HOTPLUG_ACTION.INSERT_CDROM_IMAGE; + } + + return VOLUME_HOTPLUG_ACTION.EJECT_CDROM_IMAGE; + } + + return VOLUME_HOTPLUG_ACTION.DETACH_DISK; + }, + + getVolumeHotplugActionLabel(volume) { + const labels = { + [VOLUME_HOTPLUG_ACTION.DETACH_DISK]: 'harvester.virtualMachine.hotUnplug.detachVolume.actionLabel', + [VOLUME_HOTPLUG_ACTION.INSERT_CDROM_IMAGE]: 'harvester.modal.insertCdRomVolume.actionLabel', + [VOLUME_HOTPLUG_ACTION.EJECT_CDROM_IMAGE]: 'harvester.virtualMachine.hotUnplug.ejectCdRomVolume.actionLabel', + }; + + return labels[this.getVolumeHotplugAction(volume)]; + }, + + hotplugVolume(volume) { + const calls = { + [VOLUME_HOTPLUG_ACTION.DETACH_DISK]: () => this.vm.unplugVolume(volume.name), + [VOLUME_HOTPLUG_ACTION.INSERT_CDROM_IMAGE]: () => this.vm.insertCdRomVolume(volume.name), + [VOLUME_HOTPLUG_ACTION.EJECT_CDROM_IMAGE]: () => this.vm.ejectCdRomVolume(volume.name), + }; + + return calls[this.getVolumeHotplugAction(volume)](); }, componentFor(type) { @@ -346,12 +391,12 @@ export default {
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 e136ce0c..46e68a7c 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/vmImage.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineVolume/type/vmImage.vue @@ -14,6 +14,7 @@ import { _VIEW } from '@shell/config/query-params'; import LabelValue from '@shell/components/LabelValue'; import { ucFirst } from '@shell/utils/string'; import { GIBIBYTE } from '../../../../utils/unit'; +import { EMPTY_IMAGE } from '../../../../utils/vm'; export default { name: 'HarvesterEditVMImage', @@ -96,8 +97,20 @@ export default { return this.mode === _VIEW; }, + isExistingCdrom() { + return this.value.type === 'cd-rom' && !this.value.newCreateId; + }, + + isEmptyImage() { + return this.value.image === EMPTY_IMAGE; + }, + + isHotplugCdRomFeatureEnabled() { + return this.$store.getters['harvester-common/getFeatureEnabled']('hotplugCdRom'); + }, + imagesOption() { - return this.images + const images = this.images .filter((image) => { if (!image.isReady) return false; @@ -114,6 +127,19 @@ export default { value: image.id, disabled: image.isImportedImage })); + + const options = []; + + if (this.isHotplugCdRomFeatureEnabled) { + options.push({ + label: this.t('harvester.virtualMachine.volume.emptyImage'), + value: EMPTY_IMAGE, + disabled: false + }); + } + options.push(...images); + + return options; }, imageName() { @@ -179,6 +205,7 @@ export default { 'value.type'(neu) { if (neu === 'cd-rom') { this.value['bus'] = 'sata'; + this.updateHotpluggable(); this.update(); } }, @@ -221,12 +248,48 @@ export default { return label; }, + update() { this.value.hasDiskError = this.showDiskTooSmallError; this.$emit('update'); }, + updateHotpluggable() { + if (this.value.type !== 'cd-rom') { + this.value['hotpluggable'] = false; + } else { + this.value['hotpluggable'] = (this.value.bus === 'sata'); + } + }, + + onTypeChange() { + if (this.value.image === EMPTY_IMAGE && this.value.type !== 'cd-rom') { + this.value['image'] = ''; + } + + this.updateHotpluggable(); + this.update(); + }, + + onBusChange() { + if (this.value.image === EMPTY_IMAGE && this.value.bus !== 'sata') { + this.value['image'] = ''; + } + + this.updateHotpluggable(); + this.update(); + }, + onImageChange() { + if (this.value.image === EMPTY_IMAGE) { + this.value['type'] = 'cd-rom'; + this.value['bus'] = 'sata'; + this.value['size'] = `0${ GIBIBYTE }`; + this.update(); + + return; + } + const imageResource = this.$store.getters['harvester/all'](HCI.IMAGE)?.find( (I) => this.value.image === I.id); const isIsoImage = /iso$/i.test(imageResource?.imageSuffix); const imageSize = Math.max(imageResource?.status?.size, imageResource?.status?.virtualSize); @@ -234,6 +297,7 @@ export default { if (isIsoImage) { this.value['type'] = 'cd-rom'; this.value['bus'] = 'sata'; + this.updateHotpluggable(); } else { this.value['type'] = 'disk'; this.value['bus'] = 'virtio'; @@ -256,6 +320,10 @@ export default { }, checkImageExists(imageId) { + if (imageId === EMPTY_IMAGE) { + return; + } + if (!!imageId && this.imagesOption.length > 0 && !findBy(this.imagesOption, 'value', imageId)) { this.$store.dispatch('growl/error', { title: this.$store.getters['i18n/t']('harvester.vmTemplate.tips.notExistImage.title', { name: imageId }), @@ -283,6 +351,7 @@ export default { >
@@ -323,7 +393,7 @@ export default { > @@ -374,7 +444,8 @@ export default { :label="t('harvester.virtualMachine.volume.bus')" :mode="mode" :options="InterfaceOption" - @update:value="update" + :disabled="!isCreate && isExistingCdrom" + @update:value="onBusChange" /> diff --git a/pkg/harvester/l10n/en-us.yaml b/pkg/harvester/l10n/en-us.yaml index b86dd51e..b8b33cb4 100644 --- a/pkg/harvester/l10n/en-us.yaml +++ b/pkg/harvester/l10n/en-us.yaml @@ -164,6 +164,11 @@ harvester: vmNetwork: Virtual Machine Network macAddress: MAC Address macAddressTooltip: If left blank, the MAC address will be automatically generated. + insertCdRomVolume: + success: '{ imageName } is inserted into device { deviceName }.' + title: Insert Image + image: Image + actionLabel: Insert Image cpuMemoryHotplug: success: 'CPU and Memory are updated to the virtual machine { vm }.' title: Edit CPU and Memory @@ -190,7 +195,7 @@ harvester: title: Restart Virtual Machine tip: Restart the virtual machine for configuration changes to take effect. cancel: Save - + notification: title: succeed: Succeed @@ -631,6 +636,10 @@ harvester: title: 'Are you sure that you want to detach volume {name}?' actionLabel: Detach Volume success: 'Volume { name } is detached successfully.' + ejectCdRomVolume: + title: 'Are you sure that you want to eject image from device {name}?' + actionLabel: Eject Image + success: 'Image from device { name } is ejected successfully.' detachNIC: title: 'Are you sure that you want to detach network interface {name}?' actionLabel: Detach Network Interface @@ -737,6 +746,7 @@ harvester: unmount: title: Are you sure? message: Are you sure you want to unmount this volume? + emptyImage: No media network: title: Network addNetwork: Add Network @@ -1008,7 +1018,7 @@ harvester: createTitle: Create Schedule createButtonText: Create Schedule scheduleType: Virtual Machine Schedule Type - cron: + cron: label: Cron Schedule editButton: Edit detail: diff --git a/pkg/harvester/mixins/harvester-vm/index.js b/pkg/harvester/mixins/harvester-vm/index.js index 5690a249..79dfacce 100644 --- a/pkg/harvester/mixins/harvester-vm/index.js +++ b/pkg/harvester/mixins/harvester-vm/index.js @@ -22,7 +22,7 @@ import { } from '../../config/harvester-map'; import { HCI_SETTING } from '../../config/settings'; import { HCI } from '../../types'; -import { parseVolumeClaimTemplates } from '../../utils/vm'; +import { parseVolumeClaimTemplates, EMPTY_IMAGE } from '../../utils/vm'; import impl, { QGA_JSON, USB_TABLET } from './impl'; import { GIBIBYTE } from '../../utils/unit'; import { VOLUME_MODE } from '@pkg/harvester/config/types'; @@ -511,12 +511,15 @@ export default { const type = DISK?.cdrom ? CD_ROM : DISK?.disk ? HARD_DISK : ''; - if (volume?.containerDisk) { // SOURCE_TYPE.CONTAINER + if (type === CD_ROM && volume === undefined) { + // Empty CD_ROM + source = SOURCE_TYPE.IMAGE; + image = EMPTY_IMAGE; + size = `0${ GIBIBYTE }`; + } else if (volume.containerDisk) { // SOURCE_TYPE.CONTAINER source = SOURCE_TYPE.CONTAINER; container = volume.containerDisk.image; - } - - if (volume.persistentVolumeClaim && volume.persistentVolumeClaim?.claimName) { + } else if (volume.persistentVolumeClaim && volume.persistentVolumeClaim?.claimName) { volumeName = volume.persistentVolumeClaim.claimName; const DVT = _volumeClaimTemplates.find( (T) => T.metadata.name === volumeName); @@ -704,6 +707,14 @@ export default { } }, + needVolume(R) { + if (R.image === EMPTY_IMAGE) { + return false; + } + + return true; + }, + parseDiskRows(disk) { const disks = []; const volumes = []; @@ -711,18 +722,18 @@ export default { const volumeClaimTemplates = []; disk.forEach( (R, index) => { - const prefixName = this.value.metadata?.name || ''; - const dataVolumeName = this.parseDataVolumeName(R, prefixName); - const _disk = this.parseDisk(R, index); - const _volume = this.parseVolume(R, dataVolumeName); - const _dataVolumeTemplate = this.parseVolumeClaimTemplate(R, dataVolumeName); disks.push(_disk); - volumes.push(_volume); - diskNameLabels.push(dataVolumeName); - if (R.source !== SOURCE_TYPE.CONTAINER) { + if (this.needVolume(R)) { + const prefixName = this.value.metadata?.name || ''; + const dataVolumeName = this.parseDataVolumeName(R, prefixName); + const _volume = this.parseVolume(R, dataVolumeName); + const _dataVolumeTemplate = this.parseVolumeClaimTemplate(R, dataVolumeName); + + volumes.push(_volume); + diskNameLabels.push(dataVolumeName); volumeClaimTemplates.push(_dataVolumeTemplate); } }); diff --git a/pkg/harvester/models/kubevirt.io.virtualmachine.js b/pkg/harvester/models/kubevirt.io.virtualmachine.js index 15717404..26830630 100644 --- a/pkg/harvester/models/kubevirt.io.virtualmachine.js +++ b/pkg/harvester/models/kubevirt.io.virtualmachine.js @@ -183,7 +183,7 @@ export default class VirtVm extends HarvesterResource { }, { action: 'ejectCDROM', - enabled: !!this.actions?.ejectCdRom, + enabled: !this.hotplugCdRomEnabled && !!this.actions?.ejectCdRom, icon: 'icon icon-delete', label: this.t('harvester.action.ejectCDROM') }, @@ -401,6 +401,17 @@ export default class VirtVm extends HarvesterResource { }); } + ejectCdRomVolume(diskName) { + const resources = this; + + this.$dispatch('promptModal', { + resources, + name: diskName, + type: 'cdrom', + component: 'HarvesterHotUnplug', + }); + } + unplugNIC(networkName) { const resources = this; @@ -523,6 +534,16 @@ export default class VirtVm extends HarvesterResource { }); } + insertCdRomVolume(diskName) { + const resources = this; + + this.$dispatch('promptModal', { + resources, + name: diskName, + component: 'HarvesterInsertCdRomVolume', + }); + } + addHotplugNic(resources = this) { this.$dispatch('promptModal', { resources, @@ -1267,6 +1288,10 @@ export default class VirtVm extends HarvesterResource { return this.$rootGetters['harvester-common/getFeatureEnabled']('hotplugNic'); } + get hotplugCdRomEnabled() { + return this.$rootGetters['harvester-common/getFeatureEnabled']('hotplugCdRom'); + } + get isBackupTargetUnavailable() { const allSettings = this.$rootGetters['harvester/all'](HCI.SETTING) || []; const backupTargetSetting = allSettings.find( (O) => O.id === 'backup-target'); diff --git a/pkg/harvester/utils/vm.js b/pkg/harvester/utils/vm.js index ca7d6fbc..d5881898 100644 --- a/pkg/harvester/utils/vm.js +++ b/pkg/harvester/utils/vm.js @@ -9,3 +9,5 @@ export function parseVolumeClaimTemplates(data) { return out; } + +export const EMPTY_IMAGE = 'EMPTY_IMAGE';