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';