diff --git a/pkg/harvester/dialog/HarvesterAddHotplugVolumeModal.vue b/pkg/harvester/dialog/HarvesterAddHotplugVolumeModal.vue index 83085523..9ddd1d94 100644 --- a/pkg/harvester/dialog/HarvesterAddHotplugVolumeModal.vue +++ b/pkg/harvester/dialog/HarvesterAddHotplugVolumeModal.vue @@ -2,13 +2,16 @@ import { exceptionToErrorsArray } from '@shell/utils/error'; import { sortBy } from '@shell/utils/sort'; import { mapGetters } from 'vuex'; -import { PVC } from '@shell/config/types'; +import { PVC, STORAGE_CLASS, LONGHORN_DRIVER } from '@shell/config/types'; import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations'; +import { VOLUME_MODE } from '@pkg/harvester/config/types'; +import { HCI } from '@pkg/harvester/types'; 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 { Checkbox } from '@components/Form/Checkbox'; export default { name: 'HotplugVolumeModal', @@ -16,7 +19,7 @@ export default { emits: ['close'], components: { - AsyncButton, Card, LabeledInput, LabeledSelect, Banner + AsyncButton, Card, LabeledInput, LabeledSelect, Banner, Checkbox }, props: { @@ -28,14 +31,19 @@ export default { async fetch() { this.allPVCs = await this.$store.dispatch('harvester/findAll', { type: PVC }); + this.allStorageClasses = await this.$store.dispatch('harvester/findAll', { type: STORAGE_CLASS }); + this.allVMs = await this.$store.dispatch('harvester/findAll', { type: HCI.VM }); }, data() { return { - diskName: '', - volumeName: '', - errors: [], - allPVCs: [], + diskName: '', + volumeName: '', + shareable: false, + errors: [], + allPVCs: [], + allStorageClasses: [], + allVMs: [], }; }, @@ -50,6 +58,31 @@ export default { return this.resources[0]; }, + // claim names already attached to the VM this modal was opened for + currentVMClaimNames() { + const volumes = this.actionResource?.spec?.template?.spec?.volumes || []; + + return volumes.map((vol) => vol.persistentVolumeClaim?.claimName).filter((name) => !!name); + }, + + // claim names attached to other VMs in the same namespace + otherVMClaimNames() { + const out = []; + + this.allVMs.forEach((vm) => { + if (vm.metadata.namespace !== this.actionResource?.metadata?.namespace || vm.id === this.actionResource?.id) { + return; + } + (vm.spec?.template?.spec?.volumes || []).forEach((vol) => { + if (vol.persistentVolumeClaim?.claimName) { + out.push(vol.persistentVolumeClaim.claimName); + } + }); + }); + + return out; + }, + volumeOption() { return sortBy( this.PVCs @@ -61,6 +94,14 @@ export default { if (pvc.isGoldenImageVolume) { return false; } + // a volume attached to this VM can never be attached to it again + if (this.currentVMClaimNames.includes(pvc.metadata.name)) { + return false; + } + // a volume attached to another VM can only be re-attached as a shareable disk + if (this.otherVMClaimNames.includes(pvc.metadata.name) && !this.isShareableCapablePVC(pvc)) { + return false; + } return true; }) @@ -73,19 +114,62 @@ export default { 'label' ); }, + + selectedPVC() { + return this.PVCs.find((P) => P.metadata.name === this.volumeName); + }, + + isShareableCapable() { + return this.selectedPVC ? this.isShareableCapablePVC(this.selectedPVC) : false; + }, + }, + + watch: { + isShareableCapable(neu) { + if (!neu) { + this.shareable = false; + } + }, }, methods: { + isShareableCapablePVC(pvc) { + const pvcSpec = pvc?.spec; + + if (!pvcSpec) { + return false; + } + + const isRWX = (pvcSpec.accessModes || []).includes('ReadWriteMany'); + const isBlock = pvcSpec.volumeMode === VOLUME_MODE.BLOCK; + const storageClass = this.allStorageClasses.find((sc) => sc.name === pvcSpec.storageClassName); + + // fail closed: without a resolved StorageClass the provisioner + // requirement cannot be evaluated + if (!storageClass) { + return false; + } + + return isRWX && isBlock && storageClass.provisioner !== LONGHORN_DRIVER; + }, + close() { this.diskName = ''; this.volumeName = ''; + this.shareable = false; this.$emit('close'); }, async save(buttonCb) { if (this.actionResource) { try { - const res = await this.actionResource.doAction('addVolume', { volumeSourceName: this.volumeName, diskName: this.diskName }, {}, false); + const input = { volumeSourceName: this.volumeName, diskName: this.diskName }; + + if (this.isShareableCapable && this.shareable) { + input.shareable = true; + } + + const res = await this.actionResource.doAction('addVolume', input, {}, false); if (res._status === 200 || res._status === 204) { this.$store.dispatch('growl/success', { @@ -141,6 +225,19 @@ export default { class="mt-20" required /> + + P.metadata.name === this.value.volumeName ); }, + storageClasses() { + return this.$store.getters['harvester/all'](STORAGE_CLASS) || []; + }, + + isShareableCapable() { + const pvcSpec = this.pvcResource?.spec; + + if (!pvcSpec) { + return false; + } + + const isRWX = (pvcSpec.accessModes || []).includes('ReadWriteMany'); + const isBlock = pvcSpec.volumeMode === VOLUME_MODE.BLOCK; + const storageClass = this.storageClasses.find((sc) => sc.name === pvcSpec.storageClassName); + + // fail closed: without a resolved StorageClass the provisioner + // requirement cannot be evaluated + if (!storageClass) { + return false; + } + + return isRWX && isBlock && storageClass.provisioner !== LONGHORN_DRIVER; + }, + volumeOption() { return sortBy( this.allPVCs @@ -145,6 +171,7 @@ export default { this.value.size = pvcResource.spec.resources.requests.storage; this.value.storageClassName = pvcResource.spec.storageClassName; this.value.volumeMode = pvcResource.spec.volumeMode; + this.value.shareable = false; this.update(); }, @@ -155,6 +182,13 @@ export default { } }, + isShareableCapable(neu) { + if (!neu && this.value.shareable) { + this.value.shareable = false; + this.update(); + } + }, + pvcResource: { handler(pvc) { if (!this.value.volumeName && pvc?.metadata?.name) { @@ -303,6 +337,26 @@ export default { /> +
+ + +
{ + if (disk.shareable && !disks.find((D) => D.name === disk.name)?.shareable) { + delete disk.shareable; + } + }); + let spec = { ...this.spec, runStrategy: this.runStrategy, @@ -1253,6 +1262,10 @@ export default { out.cdrom = { bus: R.bus }; } + if (R.shareable) { + out.shareable = true; + } + out.bootOrder = index + 1; return out;