diff --git a/pkg/harvester/config/feature-flags.js b/pkg/harvester/config/feature-flags.js index 4aa24d04..e55b4726 100644 --- a/pkg/harvester/config/feature-flags.js +++ b/pkg/harvester/config/feature-flags.js @@ -66,10 +66,12 @@ const FEATURE_FLAGS = { 'instanceManagerResourcesSetting', 'rwxNetworkSetting', 'createPVCWithDataVolume', - 'clusterPodSecurityStandardSetting' + 'clusterPodSecurityStandardSetting', ], 'v1.8.1': [], - 'v1.9.0': [], + 'v1.9.0': [ + 'supportFilesystem', + ], }; const generateFeatureFlags = () => { diff --git a/pkg/harvester/config/types.js b/pkg/harvester/config/types.js index 71fef7a9..956e26b4 100644 --- a/pkg/harvester/config/types.js +++ b/pkg/harvester/config/types.js @@ -46,3 +46,9 @@ export const CDI_POPULATOR_KIND = { VOLUME_IMPORT_SOURCE: 'VolumeImportSource', VOLUME_CLONE_SOURCE: 'VolumeCloneSource', }; + +export const FILESYSTEM_SOURCE_TYPE = { + CONFIGMAP: 'configmap', + SECRET: 'secret', + SERVICEACCOUNT: 'serviceaccount', +}; diff --git a/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue b/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue index 3c56ef9f..bdded934 100644 --- a/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue +++ b/pkg/harvester/edit/harvesterhci.io.virtualmachinetemplateversion.vue @@ -26,6 +26,7 @@ import CpuMemory from './kubevirt.io.virtualmachine/VirtualMachineCpuMemory'; import CpuModel from './kubevirt.io.virtualmachine/VirtualMachineCpuModel'; import CloudConfig from './kubevirt.io.virtualmachine/VirtualMachineCloudConfig'; import SSHKey from './kubevirt.io.virtualmachine/VirtualMachineSSHKey'; +import Filesystem from './kubevirt.io.virtualmachine/VirtualMachineFilesystem'; export default { name: 'HarvesterEditVMTemplate', @@ -51,6 +52,7 @@ export default { UnitInput, Banner, KeyValue, + Filesystem, }, mixins: [CreateEditView, VM_MIXIN], @@ -95,6 +97,10 @@ export default { secretNamePrefix() { return this.templateValue?.metadata?.name; }, + + filesystemEnabled() { + return this.$store.getters['harvester-common/getFeatureEnabled']('supportFilesystem'); + }, }, watch: { @@ -154,6 +160,7 @@ export default { mounted() { this.imageId = this.diskRows[0]?.image || ''; + this['filesystemRows'] = this.getFilesystemRows(this.value.spec.vm); }, methods: { @@ -349,6 +356,19 @@ export default { + + + + +import { mapGetters } from 'vuex'; +import { Banner } from '@components/Banner'; +import LabeledSelect from '@shell/components/form/LabeledSelect'; +import { LabeledInput } from '@components/Form/LabeledInput'; +import { CONFIG_MAP, SECRET, SERVICE_ACCOUNT } from '@shell/config/types'; +import { _VIEW } from '@shell/config/query-params'; +import CopyToClipboard from '@shell/components/CopyToClipboard'; +import MessageLink from '@shell/components/MessageLink'; +import { FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types'; + +const MAX_FILESYSTEMS = 3; + +const { CONFIGMAP: FS_TYPE_CONFIGMAP, SECRET: FS_TYPE_SECRET, SERVICEACCOUNT: FS_TYPE_SERVICEACCOUNT } = FILESYSTEM_SOURCE_TYPE; + +const DEFAULT_VOLUME_NAMES = { + [FS_TYPE_CONFIGMAP]: 'appconfigfs', + [FS_TYPE_SECRET]: 'appsecretfs', + [FS_TYPE_SERVICEACCOUNT]: 'appserviceaccountfs', +}; + +const FS_TYPE_OPTIONS = [ + { label: 'ConfigMap', value: FS_TYPE_CONFIGMAP }, + { label: 'Secret', value: FS_TYPE_SECRET }, + { label: 'ServiceAccount', value: FS_TYPE_SERVICEACCOUNT }, +]; + +function emptyRow() { + return { + fsType: FS_TYPE_CONFIGMAP, + volumeName: DEFAULT_VOLUME_NAMES[FS_TYPE_CONFIGMAP], + resourceName: '', + }; +} + +export default { + name: 'VirtualMachineFilesystem', + + emits: ['update:value'], + + components: { + Banner, + LabeledSelect, + LabeledInput, + CopyToClipboard, + MessageLink, + }, + + props: { + mode: { + type: String, + default: 'create', + }, + namespace: { + type: String, + default: '', + }, + value: { + type: Array, + default: () => [], + }, + }, + + data() { + return { rows: this.value.length > 0 ? this.value.map((r) => ({ ...r })) : [emptyRow()] }; + }, + + watch: { + value(newVal) { + if (newVal) { + const incoming = JSON.stringify(newVal); + const current = JSON.stringify(this.rows); + + if (incoming !== current) { + this.rows = newVal.map((r) => ({ ...r })); + } + } + }, + + rows: { + deep: true, + handler(val) { + this.$emit('update:value', val.map((r) => ({ ...r }))); + }, + }, + }, + + computed: { + ...mapGetters({ t: 'i18n/t' }), + + inStore() { + return this.$store.getters['currentProduct'].inStore; + }, + + configMaps() { + return this.$store.getters[`${ this.inStore }/all`](CONFIG_MAP) + .filter((cm) => !this.namespace || cm.metadata.namespace === this.namespace) + .map((cm) => ({ label: cm.metadata.name, value: cm.metadata.name })); + }, + + secrets() { + return this.$store.getters[`${ this.inStore }/all`](SECRET) + .filter((s) => !this.namespace || s.metadata.namespace === this.namespace) + .map((s) => ({ label: s.metadata.name, value: s.metadata.name })); + }, + + serviceAccounts() { + return this.$store.getters[`${ this.inStore }/all`](SERVICE_ACCOUNT) + .filter((sa) => !this.namespace || sa.metadata.namespace === this.namespace) + .map((sa) => ({ label: sa.metadata.name, value: sa.metadata.name })); + }, + + canAddRow() { + return this.rows.length < MAX_FILESYSTEMS; + }, + + isView() { + return this.mode === _VIEW; + }, + + completedRows() { + return this.rows.filter((r) => r.fsType && r.volumeName && r.resourceName); + }, + + allMountCommands() { + return this.completedRows.map((r) => this.mountCommands(r)).join('\n'); + }, + }, + + methods: { + fsTypeOptions(currentIndex) { + const usedTypes = this.rows + .filter((_, i) => i !== currentIndex) + .map((r) => r.fsType); + + return FS_TYPE_OPTIONS.filter((opt) => !usedTypes.includes(opt.value)); + }, + + resourceOptions(fsType) { + if (fsType === FS_TYPE_CONFIGMAP) return this.configMaps; + if (fsType === FS_TYPE_SECRET) return this.secrets; + if (fsType === FS_TYPE_SERVICEACCOUNT) return this.serviceAccounts; + + return []; + }, + + onFsTypeChange(row, newType) { + row.fsType = newType; + row.volumeName = DEFAULT_VOLUME_NAMES[newType] || ''; + row.resourceName = ''; + }, + + addRow() { + if (this.canAddRow) { + const usedTypes = this.rows.map((r) => r.fsType); + const nextType = FS_TYPE_OPTIONS.find((opt) => !usedTypes.includes(opt.value))?.value || FS_TYPE_CONFIGMAP; + + this.rows.push({ + fsType: nextType, + volumeName: DEFAULT_VOLUME_NAMES[nextType] || '', + resourceName: '', + }); + } + }, + + removeRow(index) { + this.rows.splice(index, 1); + }, + + mountCommands(row) { + const vol = row.volumeName || ''; + + return `- mkdir -p /mnt/${ vol }\n- mount -t virtiofs ${ vol } /mnt/${ vol }`; + }, + }, +}; + + + + + diff --git a/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue b/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue index efb10afa..f477548b 100644 --- a/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue +++ b/pkg/harvester/edit/kubevirt.io.virtualmachine/index.vue @@ -37,6 +37,7 @@ import Network from './VirtualMachineNetwork'; import Volume from './VirtualMachineVolume'; import SSHKey from './VirtualMachineSSHKey'; import Reserved from './VirtualMachineReserved'; +import Filesystem from './VirtualMachineFilesystem'; import { Banner } from '@components/Banner'; import MessageLink from '@shell/components/MessageLink'; @@ -72,6 +73,7 @@ export default { Banner, MessageLink, UsbDevices, + Filesystem, }, mixins: [CreateEditView, VM_MIXIN], @@ -218,6 +220,9 @@ export default { usbPassthroughEnabled() { return this.$store.getters['harvester-common/getFeatureEnabled']('usbPassthrough'); }, + filesystemEnabled() { + return this.$store.getters['harvester-common/getFeatureEnabled']('supportFilesystem'); + }, }, watch: { @@ -321,6 +326,7 @@ export default { const diskRows = this.getDiskRows(this.value); this['diskRows'] = diskRows; + this['filesystemRows'] = this.getFilesystemRows(this.value); const templateId = this.$route.query.templateId; const templateVersionId = this.$route.query.versionId; @@ -783,10 +789,23 @@ export default { /> + + + + @@ -805,7 +824,7 @@ export default { @@ -826,7 +845,7 @@ export default { @@ -847,7 +866,7 @@ export default {
diff --git a/pkg/harvester/l10n/en-us.yaml b/pkg/harvester/l10n/en-us.yaml index 91c91d9b..9b2581c2 100644 --- a/pkg/harvester/l10n/en-us.yaml +++ b/pkg/harvester/l10n/en-us.yaml @@ -355,6 +355,7 @@ harvester: snapshots: Snapshots instanceLabel: Instance Labels annotations: Annotations + filesystem: Filesystem Volume fields: version: Version name: Name @@ -830,6 +831,15 @@ harvester: username: Username password: Password reservedMemory: Reserved Memory + filesystem: + description: Harvester supports filesystem volumes for VM via virtiofs. + type: Filesystem Type + volume: Volume + resource: Resource + add: Add + mountBannerHint: "Please update the mount path (e.g. /mnt/appconfigfs) to your preferred location, then add the corresponding commands to the" + mountBannerHintLink: "runcmd" + mountBannerHintSuffix: "in Advanced tab User Data." machineTypeTip: 'Specify a processor architecture to emulate. To see a list of supported architectures, run: qemu-system-x86_64 -cpu ?' detail: tabs: diff --git a/pkg/harvester/mixins/harvester-vm/index.js b/pkg/harvester/mixins/harvester-vm/index.js index 24ca3964..074019d7 100644 --- a/pkg/harvester/mixins/harvester-vm/index.js +++ b/pkg/harvester/mixins/harvester-vm/index.js @@ -12,7 +12,7 @@ import { base64Decode } from '@shell/utils/crypto'; import { formatSi, parseSi } from '@shell/utils/units'; import { _CLONE, _CREATE, _VIEW } from '@shell/config/query-params'; import { - PV, PVC, STORAGE_CLASS, NODE, SECRET, CONFIG_MAP, NETWORK_ATTACHMENT, NAMESPACE, LONGHORN + PV, PVC, STORAGE_CLASS, NODE, SECRET, CONFIG_MAP, SERVICE_ACCOUNT, NETWORK_ATTACHMENT, NAMESPACE, LONGHORN } from '@shell/config/types'; import { HOSTNAME } from '@shell/config/labels-annotations'; import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations'; @@ -25,7 +25,7 @@ import { HCI } from '../../types'; 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'; +import { VOLUME_MODE, FILESYSTEM_SOURCE_TYPE } from '@pkg/harvester/config/types'; const LONGHORN_V2_DATA_ENGINE = 'longhorn-system/v2-data-engine'; @@ -102,6 +102,8 @@ export default { vmims: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VMIM }), vms: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VM }), secrets: this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET }), + configMaps: this.$store.dispatch(`${ inStore }/findAll`, { type: CONFIG_MAP }), + serviceAccounts: this.$store.dispatch(`${ inStore }/findAll`, { type: SERVICE_ACCOUNT }), addons: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.ADD_ONS }), longhornV2Engine: this.$store.dispatch(`${ inStore }/find`, { type: LONGHORN.SETTINGS, id: LONGHORN_V2_DATA_ENGINE }), }; @@ -156,6 +158,7 @@ export default { imageId: '', diskRows: [], networkRows: [], + filesystemRows: [], machineType: '', machineTypes: [], secretName: '', @@ -440,6 +443,7 @@ export default { this['imageId'] = imageId; this['diskRows'] = diskRows; + this['filesystemRows'] = this.getFilesystemRows(vm); this.refreshYamlEditor(); }, @@ -641,6 +645,80 @@ export default { this.parseAccessCredentials(); this.parseNetworkRows(this.networkRows); this.parseDiskRows(this.diskRows); + this.parseFilesystemRows(); + }, + + getFilesystemRows(vm) { + const _filesystems = vm.spec.template.spec.domain.devices?.filesystems || []; + const _volumes = vm.spec.template.spec.volumes || []; + + return _filesystems.map((fs) => { + const volume = _volumes.find((v) => v.name === fs.name); + let fsType = FILESYSTEM_SOURCE_TYPE.CONFIGMAP; + let resourceName = ''; + + if (volume?.configMap) { + fsType = FILESYSTEM_SOURCE_TYPE.CONFIGMAP; + resourceName = volume.configMap.name; + } else if (volume?.secret) { + fsType = FILESYSTEM_SOURCE_TYPE.SECRET; + resourceName = volume.secret.secretName; + } else if (volume?.serviceAccount) { + fsType = FILESYSTEM_SOURCE_TYPE.SERVICEACCOUNT; + resourceName = volume.serviceAccount.serviceAccountName; + } + + return { + fsType, + volumeName: fs.name, + resourceName, + }; + }); + }, + + parseFilesystemRows() { + const completedRows = this.filesystemRows.filter( + (r) => r.fsType && r.volumeName && r.resourceName + ); + + const filesystems = completedRows.map((r) => ({ + name: r.volumeName, + virtiofs: {}, + })); + + const fsVolumes = completedRows.map((r) => { + if (r.fsType === FILESYSTEM_SOURCE_TYPE.CONFIGMAP) { + return { + name: r.volumeName, + configMap: { name: r.resourceName }, + }; + } else if (r.fsType === FILESYSTEM_SOURCE_TYPE.SECRET) { + return { + name: r.volumeName, + secret: { secretName: r.resourceName }, + }; + } else if (r.fsType === FILESYSTEM_SOURCE_TYPE.SERVICEACCOUNT) { + return { + name: r.volumeName, + serviceAccount: { serviceAccountName: r.resourceName }, + }; + } + + return null; + }).filter(Boolean); + + if (filesystems.length > 0) { + this.spec.template.spec.domain.devices['filesystems'] = filesystems; + } else { + delete this.spec.template.spec.domain.devices['filesystems']; + } + + if (fsVolumes.length > 0) { + if (!this.spec.template.spec.volumes) { + this.spec.template.spec['volumes'] = []; + } + this.spec.template.spec.volumes.push(...fsVolumes); + } }, parseOther() {