feat: add cdrom hotplug volume (#703)

Signed-off-by: Tim Liou <tim.liou@suse.com>
This commit is contained in:
Tim Liou 2026-02-13 16:33:50 +08:00 committed by GitHub
parent c8a613874a
commit 3fdc9f03a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 429 additions and 35 deletions

View File

@ -58,7 +58,9 @@ const FEATURE_FLAGS = {
'resumeUpgradePausedNode',
],
'v1.7.1': [],
'v1.8.0': []
'v1.8.0': [
'hotplugCdRom',
],
};
const generateFeatureFlags = () => {

View File

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

View File

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

View File

@ -0,0 +1,198 @@
<script>
import { exceptionToErrorsArray } from '@shell/utils/error';
import { mapState, mapGetters } from 'vuex';
import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import { HCI } from '../types';
export default {
name: 'HarvesterInsertCdRomVolume',
emits: ['close'],
components: {
AsyncButton,
Card,
LabeledInput,
LabeledSelect,
Banner
},
props: {
resources: {
type: Array,
required: true
}
},
async fetch() {
try {
this.images = await this.$store.dispatch('harvester/findAll', { type: HCI.IMAGE });
} catch (err) {
this.errors = exceptionToErrorsArray(err);
this.images = [];
}
},
data() {
return {
imageName: '',
images: [],
errors: [],
};
},
computed: {
...mapState('action-menu', ['modalData']),
...mapGetters({ t: 'i18n/t' }),
actionResource() {
return this.resources?.[0];
},
isFormValid() {
return this.imageName !== '';
},
deviceName() {
return this.modalData.name;
},
imagesOption() {
return this.images
.filter((image) => {
const labels = image.metadata?.labels || {};
const type = labels[HCI_ANNOTATIONS.IMAGE_SUFFIX];
return type === 'iso';
})
.map((image) => {
return ({
label: this.imageOptionLabel(image),
value: image.id,
disabled: image.isImportedImage
});
});
}
},
methods: {
close() {
this.imageName = '';
this.errors = [];
this.$emit('close');
},
imageOptionLabel(image) {
return `${ image.metadata.namespace }/${ image.spec.displayName }`;
},
async save(buttonCb) {
if (!this.actionResource) {
buttonCb(false);
return;
}
const payload = {
deviceName: this.deviceName,
imageName: this.imageName
};
try {
const res = await this.actionResource.doAction('insertCdRomVolume', payload);
if ([200, 204].includes(res?._status)) {
this.$store.dispatch('growl/success', {
title: this.t('generic.notification.title.succeed'),
message: this.t('harvester.modal.insertCdRomVolume.success', {
deviceName: this.deviceName,
imageName: this.imageName,
})
}, { root: true });
this.close();
buttonCb(true);
} else {
this.errors = exceptionToErrorsArray(res);
buttonCb(false);
}
} catch (err) {
this.errors = exceptionToErrorsArray(err);
buttonCb(false);
}
}
}
};
</script>
<template>
<Card
ref="modal"
name="modal"
:show-highlight-border="false"
>
<template #title>
<h4
v-clean-html="t('harvester.modal.insertCdRomVolume.title')"
class="text-default-text"
/>
</template>
<template #body>
<LabeledInput
v-model:value="deviceName"
:label="t('generic.name')"
disabled
/>
<LabeledSelect
v-model:value="imageName"
class="mt-20"
:label="t('harvester.modal.insertCdRomVolume.image')"
:options="imagesOption"
required
/>
<Banner
v-for="(err, i) in errors"
:key="i"
:label="err"
color="error"
/>
</template>
<template #actions>
<div class="actions">
<div class="buttons">
<button
type="button"
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="apply"
:disabled="!isFormValid"
@click="save"
/>
</div>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>

View File

@ -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 {
<i class="icon icon-x" />
</button>
<button
v-if="volume.hotpluggable && isView"
v-if="canDoVolumeHotplugAction(volume) && isView"
type="button"
class="role-link btn btn-sm remove"
@click="unplugVolume(volume)"
@click="hotplugVolume(volume)"
>
{{ t('harvester.virtualMachine.hotUnplug.detachVolume.actionLabel') }}
{{ t(getVolumeHotplugActionLabel(volume)) }}
</button>
</div>
<div>

View File

@ -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 {
>
<LabeledInput
v-model:value="value.name"
:disabled="!isCreate && isExistingCdrom"
:label="t('harvester.fields.name')"
required
:mode="mode"
@ -302,10 +371,11 @@ export default {
>
<LabeledSelect
v-model:value="value.type"
:disabled="!isCreate && isExistingCdrom"
:label="t('harvester.fields.type')"
:options="VOLUME_TYPE"
:mode="mode"
@update:value="update"
@update:value="onTypeChange"
/>
</InputOrDisplay>
</div>
@ -323,7 +393,7 @@ export default {
>
<LabeledSelect
v-model:value="value.image"
:disabled="idx === 0 && !isCreate && !value.newCreateId && isVirtualType"
:disabled="(idx === 0 || isExistingCdrom) && (!isCreate && !value.newCreateId && isVirtualType)"
:label="t('harvester.fields.image')"
:options="imagesOption"
:mode="mode"
@ -351,7 +421,7 @@ export default {
:label="t('harvester.fields.size')"
:mode="mode"
:required="validateRequired"
:disabled="isResizeDisabled"
:disabled="isResizeDisabled || isEmptyImage || (!isCreate && isExistingCdrom)"
:suffix="GIBIBYTE"
@update:value="update"
/>
@ -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"
/>
</InputOrDisplay>
</div>

View File

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

View File

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

View File

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

View File

@ -9,3 +9,5 @@ export function parseVolumeClaimTemplates(data) {
return out;
}
export const EMPTY_IMAGE = 'EMPTY_IMAGE';