feat: expose CDI settings (#418)

* feat: add cdi settings tab

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>

* feat: basic layout

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>

* feat: add fields mutation

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>

* refactor: rename keys

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>

* refactor: add edit mode

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>

* refactor: remove isCreate

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>

* feat: filter volume mode options

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>

---------

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
This commit is contained in:
Yiya Chen 2025-07-25 13:55:54 +08:00 committed by GitHub
parent b6ffb3e9f1
commit ed2bc3100b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 412 additions and 11 deletions

View File

@ -45,7 +45,8 @@ const FEATURE_FLAGS = {
'vmNetworkMigration', 'vmNetworkMigration',
'kubeovnVpcSubnet', 'kubeovnVpcSubnet',
'rancherClusterSetting', 'rancherClusterSetting',
'cpuMemoryHotplug' 'cpuMemoryHotplug',
'cdiSettings'
] ]
}; };

View File

@ -72,4 +72,8 @@ export const HCI = {
CUSTOM_IP: 'harvesterhci.io/custom-ip', CUSTOM_IP: 'harvesterhci.io/custom-ip',
IMPORTED_IMAGE: 'migration.harvesterhci.io/imported', IMPORTED_IMAGE: 'migration.harvesterhci.io/imported',
VM_CPU_MEMORY_HOTPLUG: 'harvesterhci.io/enableCPUAndMemoryHotplug', VM_CPU_MEMORY_HOTPLUG: 'harvesterhci.io/enableCPUAndMemoryHotplug',
FILESYSTEM_OVERHEAD: 'cdi.harvesterhci.io/filesystemOverhead',
CLONE_STRATEGY: 'cdi.harvesterhci.io/storageProfileCloneStrategy',
VOLUME_MODE_ACCESS_MODES: 'cdi.harvesterhci.io/storageProfileVolumeModeAccessModes',
VOLUME_SNAPSHOT_CLASS: 'cdi.harvesterhci.io/storageProfileVolumeSnapshotClass',
}; };

View File

@ -0,0 +1,321 @@
<script>
import { VOLUME_MODE } from '@pkg/harvester/config/types';
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import ArrayList from '@shell/components/form/ArrayList';
import { Checkbox } from '@components/Form/Checkbox';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { VOLUME_SNAPSHOT_CLASS, HCI } from '../../types';
import { HCI_SETTING } from '../../config/settings';
import { allHash } from '@shell/utils/promise';
import { _EDIT, _CREATE } from '@shell/config/query-params';
export default {
name: 'CDISettings',
props: {
value: {
type: Object,
required: true
},
mode: {
type: String,
required: true
},
provisioner: {
type: String,
required: true
},
},
components: {
ArrayList,
Checkbox,
LabeledInput,
LabeledSelect,
},
emits: ['update:cdiSettings'],
async fetch() {
const hash = {
volumeSnapshotClasses: this.$store.dispatch(`${ this.inStore }/findAll`, { type: VOLUME_SNAPSHOT_CLASS }),
csiDriverConfigSetting: this.$store.dispatch(`${ this.inStore }/find`, { type: HCI.SETTING, id: HCI_SETTING.CSI_DRIVER_CONFIG }),
};
await allHash(hash);
},
data() {
return {
cdiSettings: {
volumeModeAccessModes: [],
volumeSnapshotClass: null,
cloneStrategy: null,
filesystemOverhead: null,
},
defaultAddValue: { volumeMode: null, accessModes: [] },
noneOption: { label: 'None', value: '' },
};
},
created() {
if (this.mode === _CREATE ) {
this.setDefaultVolumeSnapshotClass();
} else {
this.initCDISettingsFromAnnotations();
}
},
computed: {
inStore() {
return this.$store.getters['currentProduct'].inStore;
},
allVolumeModeOptions() {
return Object.values(VOLUME_MODE).map((mode) => ({ label: mode, value: mode }));
},
selectedVolumeModes() {
return this.cdiSettings.volumeModeAccessModes
.map((item) => item.volumeMode)
.filter(Boolean);
},
volumeModeOptions() {
return [this.noneOption, ...this.allVolumeModeOptions].filter( (option) => !this.selectedVolumeModes.includes(option.value) );
},
allVolumeModesSelected() {
return this.selectedVolumeModes.length === this.allVolumeModeOptions.length;
},
accessModeOptions() {
return [
{ label: 'ReadWriteOnce', value: 'ReadWriteOnce' },
{ label: 'ReadOnlyMany', value: 'ReadOnlyMany' },
{ label: 'ReadWriteMany', value: 'ReadWriteMany' },
{ label: 'ReadWriteOncePod', value: 'ReadWriteOncePod' },
];
},
cloneStrategyOptions() {
return [
this.noneOption,
{ label: this.t('harvester.storage.cdiSettings.cloneStrategy.copy'), value: 'copy' },
{ label: this.t('harvester.storage.cdiSettings.cloneStrategy.snapshot'), value: 'snapshot' },
{ label: this.t('harvester.storage.cdiSettings.cloneStrategy.csiClone'), value: 'csi-clone' },
];
},
volumeSnapshotClassOptions() {
const allClasses = this.$store.getters[`${ this.inStore }/all`](VOLUME_SNAPSHOT_CLASS) || [];
const filtered = allClasses.filter((c) => c.driver === this.provisioner);
return [this.noneOption, ...filtered.map((c) => ({ label: c.name, value: c.name }))];
},
isFilesystemOverheadValid() {
const val = this.cdiSettings.filesystemOverhead;
if (val === null || val === '') return true;
const regex = /^(0(\.\d{1,3})?|1(\.0{1,3})?)$/;
return regex.test(val);
},
isCustomClass() {
return this.mode === _CREATE || this.mode === _EDIT;
}
},
watch: {
provisioner() {
this.resetCdiSettings();
this.$nextTick(this.setDefaultVolumeSnapshotClass);
},
cdiSettings: {
handler(val) {
this.$emit('update:cdiSettings', val);
},
deep: true,
immediate: true,
}
},
methods: {
setDefaultVolumeSnapshotClass() {
try {
const setting = this.$store.getters[`${ this.inStore }/byId`](HCI.SETTING, HCI_SETTING.CSI_DRIVER_CONFIG);
const config = JSON.parse(setting?.value || '{}');
const defaultClass = config?.[this.provisioner]?.volumeSnapshotClassName || null;
const allClasses = this.$store.getters[`${ this.inStore }/all`](VOLUME_SNAPSHOT_CLASS) || [];
const matched = allClasses.find((cls) => cls.name === defaultClass && cls.driver === this.provisioner);
this.cdiSettings.volumeSnapshotClass = matched?.name || null;
} catch (e) {
console.error('Failed to parse CSI config:', e); // eslint-disable-line no-console
this.cdiSettings.volumeSnapshotClass = null;
}
},
initCDISettingsFromAnnotations() {
const annotations = this.value?.metadata?.annotations || {};
let volumeModeAccessModes = [];
const rawVolumeMode = annotations[HCI_ANNOTATIONS.VOLUME_MODE_ACCESS_MODES];
if (rawVolumeMode) {
try {
const parsed = JSON.parse(rawVolumeMode);
volumeModeAccessModes = Object.entries(parsed).map(([volumeMode, accessModes]) => ({
volumeMode,
accessModes: Array.isArray(accessModes) ? accessModes : [],
}));
} catch (e) {
console.error('Failed to parse annotation:', e); // eslint-disable-line no-console
}
}
if (volumeModeAccessModes.length) {
this.cdiSettings.volumeModeAccessModes = volumeModeAccessModes;
}
if (annotations[HCI_ANNOTATIONS.VOLUME_SNAPSHOT_CLASS]) {
this.cdiSettings.volumeSnapshotClass = annotations[HCI_ANNOTATIONS.VOLUME_SNAPSHOT_CLASS];
}
if (annotations[HCI_ANNOTATIONS.CLONE_STRATEGY]) {
this.cdiSettings.cloneStrategy = annotations[HCI_ANNOTATIONS.CLONE_STRATEGY];
}
if (annotations[HCI_ANNOTATIONS.FILESYSTEM_OVERHEAD]) {
this.cdiSettings.filesystemOverhead = annotations[HCI_ANNOTATIONS.FILESYSTEM_OVERHEAD];
}
},
resetCdiSettings() {
this.cdiSettings.volumeModeAccessModes = [this.defaultAddValue];
this.cdiSettings.volumeSnapshotClass = null;
this.cdiSettings.cloneStrategy = null;
this.cdiSettings.filesystemOverhead = null;
},
validateFilesystemOverhead(e) {
const val = e.target.value;
this.cdiSettings.filesystemOverhead = val;
}
}
};
</script>
<template>
<ArrayList
v-model:value="cdiSettings.volumeModeAccessModes"
:initial-empty-row="true"
:show-header="true"
:mode="mode"
:title="t('harvester.storage.cdiSettings.volumeModeAccessModes.label')"
:add-label="t('harvester.storage.cdiSettings.volumeModeAccessModes.add')"
:default-add-value="defaultAddValue"
:protip="t('harvester.storage.cdiSettings.volumeModeAccessModes.tooltip')"
:add-disabled="allVolumeModesSelected"
>
<template #column-headers>
<div class="column-headers">
<div
class="row"
:class="{ custom: isCustomClass }"
>
<label
class="col span-3 value text-label mb-10"
for="volumeMode"
>
{{ t('harvester.storage.cdiSettings.volumeModeAccessModes.volumeMode') }}
</label>
<label
class="col span-9 value text-label mb-10"
for="accessModes"
>
{{ t('harvester.storage.cdiSettings.volumeModeAccessModes.accessModes.label') }}
</label>
</div>
</div>
</template>
<template #columns="{ row }">
<div class="row">
<div class="col span-3">
<LabeledSelect
id="volumeMode"
v-model:value="row.value.volumeMode"
:mode="mode"
:options="volumeModeOptions"
/>
</div>
<div
id="accessModes"
class="col span-9"
>
<Checkbox
v-for="opt in accessModeOptions"
:key="opt.value"
v-model:value="row.value.accessModes"
:value-when-true="opt.value"
:label="opt.label"
type="checkbox"
:mode="mode"
/>
</div>
</div>
</template>
</ArrayList>
<LabeledSelect
v-model:value="cdiSettings.volumeSnapshotClass"
class="select mt-20 mb-20"
:label="t('harvester.storage.cdiSettings.volumeSnapshotClass.label')"
:tooltip="t('harvester.storage.cdiSettings.volumeSnapshotClass.tooltip')"
:mode="mode"
:options="volumeSnapshotClassOptions"
/>
<LabeledSelect
v-model:value="cdiSettings.cloneStrategy"
class="select mb-20"
:label="t('harvester.storage.cdiSettings.cloneStrategy.label')"
:tooltip="t('harvester.storage.cdiSettings.cloneStrategy.tooltip')"
:mode="mode"
:options="cloneStrategyOptions"
/>
<LabeledInput
v-model:value="cdiSettings.filesystemOverhead"
class="select mb-20"
:label="t('harvester.storage.cdiSettings.fileSystemOverhead.label')"
:tooltip="t('harvester.storage.cdiSettings.fileSystemOverhead.tooltip')"
:mode="mode"
type="number"
:min="0"
:max="1"
:step="0.001"
:placeholder="t('harvester.storage.cdiSettings.fileSystemOverhead.placeholder')"
:status="isFilesystemOverheadValid ? null : 'error'"
@input="validateFilesystemOverhead"
/>
</template>
<style scoped lang="scss">
.column-headers .row.custom {
max-width: calc(100% - 75px);
}
.row {
align-items: center;
}
.select {
max-width: 480px;
}
</style>

View File

@ -1,4 +1,5 @@
<script> <script>
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import CreateEditView from '@shell/mixins/create-edit-view'; import CreateEditView from '@shell/mixins/create-edit-view';
import CruResource from '@shell/components/CruResource'; import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription'; import NameNsDescription from '@shell/components/form/NameNsDescription';
@ -6,11 +7,9 @@ import ArrayList from '@shell/components/form/ArrayList';
import Tab from '@shell/components/Tabbed/Tab'; import Tab from '@shell/components/Tabbed/Tab';
import Tabbed from '@shell/components/Tabbed'; import Tabbed from '@shell/components/Tabbed';
import { RadioGroup } from '@components/Form/Radio'; import { RadioGroup } from '@components/Form/Radio';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
import { _CREATE, _VIEW } from '@shell/config/query-params'; import { _CREATE, _VIEW } from '@shell/config/query-params';
import { mapFeature, UNSUPPORTED_STORAGE_DRIVERS } from '@shell/store/features'; import { mapFeature, UNSUPPORTED_STORAGE_DRIVERS } from '@shell/store/features';
import { import {
@ -22,6 +21,7 @@ import { CSI_DRIVER } from '../../types';
import Tags from '../../components/DiskTags'; import Tags from '../../components/DiskTags';
import { DATA_ENGINE_V1, DATA_ENGINE_V2 } from '../../models/harvester/persistentvolumeclaim'; import { DATA_ENGINE_V1, DATA_ENGINE_V2 } from '../../models/harvester/persistentvolumeclaim';
import { LVM_DRIVER } from '../../models/harvester/storage.k8s.io.storageclass'; import { LVM_DRIVER } from '../../models/harvester/storage.k8s.io.storageclass';
import CDISettings from './CDISettings';
export const LVM_TOPOLOGY_LABEL = 'topology.lvm.csi/node'; export const LVM_TOPOLOGY_LABEL = 'topology.lvm.csi/node';
@ -45,6 +45,7 @@ export default {
Tabbed, Tabbed,
Loading, Loading,
Tags, Tags,
CDISettings,
}, },
mixins: [CreateEditView], mixins: [CreateEditView],
@ -117,6 +118,7 @@ export default {
key: '', key: '',
values: [], values: [],
}, },
cdiSettings: {},
}; };
}, },
@ -124,10 +126,10 @@ export default {
const inStore = this.$store.getters['currentProduct'].inStore; const inStore = this.$store.getters['currentProduct'].inStore;
const hash = { const hash = {
namespaces: this.$store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE }), namespaces: this.$store.dispatch(`${ inStore }/findAll`, { type: NAMESPACE }),
storages: this.$store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS }), storages: this.$store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS }),
longhornNodes: this.$store.dispatch(`${ inStore }/findAll`, { type: LONGHORN.NODES }), longhornNodes: this.$store.dispatch(`${ inStore }/findAll`, { type: LONGHORN.NODES }),
csiDrivers: this.$store.dispatch(`${ inStore }/findAll`, { type: CSI_DRIVER }) csiDrivers: this.$store.dispatch(`${ inStore }/findAll`, { type: CSI_DRIVER }),
}; };
if (this.value.longhornV2LVMSupport) { if (this.value.longhornV2LVMSupport) {
@ -197,6 +199,14 @@ export default {
return v2DataEngine.value === 'true' ? DATA_ENGINE_V2 : DATA_ENGINE_V1; return v2DataEngine.value === 'true' ? DATA_ENGINE_V2 : DATA_ENGINE_V1;
}, },
isCDISettingsFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('cdiSettings');
},
shouldShowCDISettingsTab() {
return this.isCDISettingsFeatureEnabled && this.provisioner !== `${ LONGHORN_DRIVER }_${ DATA_ENGINE_V1 }`;
},
}, },
watch: { watch: {
@ -249,6 +259,10 @@ export default {
}); });
this.formatAllowedTopoloties(); this.formatAllowedTopoloties();
if (this.shouldShowCDISettingsTab) {
this.formatCDISettings();
}
}, },
formatAllowedTopoloties() { formatAllowedTopoloties() {
@ -270,6 +284,34 @@ export default {
if (matchLabelExpressions.length > 0) { if (matchLabelExpressions.length > 0) {
this.value.allowedTopologies = [{ matchLabelExpressions: [...matchLabelExpressions, ...lvmMatchExpression] }]; this.value.allowedTopologies = [{ matchLabelExpressions: [...matchLabelExpressions, ...lvmMatchExpression] }];
} }
},
formatCDISettings() {
const annotations = this.value.metadata.annotations || {};
const volumeModeAccessModes = {};
this.cdiSettings.volumeModeAccessModes.forEach((setting) => {
if (setting.volumeMode && Array.isArray(setting.accessModes) && setting.accessModes.length > 0) {
volumeModeAccessModes[setting.volumeMode] = setting.accessModes;
}
});
if (Object.keys(volumeModeAccessModes).length > 0) {
annotations[HCI_ANNOTATIONS.VOLUME_MODE_ACCESS_MODES] = JSON.stringify(volumeModeAccessModes);
}
if (this.cdiSettings.volumeSnapshotClass) {
annotations[HCI_ANNOTATIONS.VOLUME_SNAPSHOT_CLASS] = this.cdiSettings.volumeSnapshotClass;
}
if (this.cdiSettings.cloneStrategy) {
annotations[HCI_ANNOTATIONS.CLONE_STRATEGY] = this.cdiSettings.cloneStrategy;
}
if (this.cdiSettings.filesystemOverhead) {
annotations[HCI_ANNOTATIONS.FILESYSTEM_OVERHEAD] = this.cdiSettings.filesystemOverhead;
}
this.value.metadata.annotations = annotations;
} }
} }
}; };
@ -404,6 +446,19 @@ export default {
</template> </template>
</ArrayList> </ArrayList>
</Tab> </Tab>
<Tab
v-if="shouldShowCDISettingsTab"
name="cdiSettings"
:label="t('harvester.storage.cdiSettings.label')"
:weight="-2"
>
<CDISettings
v-model:cdi-settings="cdiSettings"
:value="value"
:mode="mode"
:provisioner="value.provisioner"
/>
</Tab>
</Tabbed> </Tabbed>
</CruResource> </CruResource>
</template> </template>

View File

@ -1029,7 +1029,7 @@ harvester:
progress: Restore in progress progress: Restore in progress
complete: Restore completed complete: Restore completed
subnet: subnet:
cidrBlock: cidrBlock:
tooltip: The subnet range in CIDR notation. Note that the CIDR blocks of different Subnets' within the same VPC cannot overlap. tooltip: The subnet range in CIDR notation. Note that the CIDR blocks of different Subnets' within the same VPC cannot overlap.
label: CIDR Block label: CIDR Block
@ -1080,8 +1080,6 @@ harvester:
placeholder: e.g. 169.254.0.1/16 placeholder: e.g. 169.254.0.1/16
remoteVpc: remoteVpc:
label: Remote VPC label: Remote VPC
network: network:
label: Virtual Machine Networks label: Virtual Machine Networks
tabs: tabs:
@ -1388,6 +1386,28 @@ harvester:
label: Internal Storage Class label: Internal Storage Class
cannotDeleteTooltip: Internal storage class volumes cannot be deleted cannotDeleteTooltip: Internal storage class volumes cannot be deleted
cannotDeleteOrDefaultTooltip: Internal storage classes cannot be deleted or set as default cannotDeleteOrDefaultTooltip: Internal storage classes cannot be deleted or set as default
cdiSettings:
label: CDI Settings
volumeModeAccessModes:
label: Volume Mode / Access Modes
tooltip: Specifies the default volume mode and access modes.
add: Add
volumeMode: Volume Mode
accessModes:
label: Access Modes
volumeSnapshotClass:
label: Volume Snapshot Class
tooltip: Sets the Volume Snapshot Class name to be used when taking snapshots of PVCs under this StorageClass.
cloneStrategy:
label: Clone Strategy
tooltip: Defines the clone strategy to use for volumes created with this StorageClass.
copy: Copy
snapshot: Snapshot
csiClone: CSI Clone
fileSystemOverhead:
label: File System Overhead
tooltip: Specifies the percentage of filesystem overhead to consider when calculating PVC size.
placeholder: e.g. 0.05 (up to 3 decimal places, between 0 and 1)
vlanConfig: vlanConfig:
title: Network Configuration title: Network Configuration