mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 04:39:15 +00:00
feat: support attaching volumes as shareable disks (#1026)
Show a Shareable checkbox when attaching an existing volume whose
PVC is RWX Block and not provisioned by Longhorn. Checking it sets
disk.shareable and shows a data-corruption warning banner.
Related issue: harvester/harvester#9650
Signed-off-by: Vicente Cheng <vicente.cheng@suse.com>
This commit is contained in:
parent
afe632b723
commit
9a935434c2
@ -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
|
||||
/>
|
||||
<Checkbox
|
||||
v-if="isShareableCapable"
|
||||
v-model:value="shareable"
|
||||
class="mt-20"
|
||||
type="checkbox"
|
||||
label-key="harvester.virtualMachine.volume.shareable.label"
|
||||
tooltip-key="harvester.virtualMachine.volume.shareable.tip"
|
||||
/>
|
||||
<Banner
|
||||
v-if="isShareableCapable && shareable"
|
||||
color="warning"
|
||||
:label="t('harvester.virtualMachine.volume.shareable.warning')"
|
||||
/>
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
|
||||
@ -5,10 +5,12 @@ import LabelValue from '@shell/components/LabelValue';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import InputOrDisplay from '@shell/components/InputOrDisplay';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { Checkbox } from '@components/Form/Checkbox';
|
||||
import { sortBy } from '@shell/utils/sort';
|
||||
import { PVC } from '@shell/config/types';
|
||||
import { PVC, STORAGE_CLASS, LONGHORN_DRIVER } from '@shell/config/types';
|
||||
import { _CREATE } from '@shell/config/query-params';
|
||||
import { HCI as HCI_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
|
||||
import { VOLUME_MODE } from '@pkg/harvester/config/types';
|
||||
import { HCI } from '../../../../types';
|
||||
import { VOLUME_TYPE, InterfaceOption } from '../../../../config/harvester-map';
|
||||
import { GIBIBYTE } from '../../../../utils/unit';
|
||||
@ -19,7 +21,7 @@ export default {
|
||||
emits: ['update'],
|
||||
|
||||
components: {
|
||||
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner
|
||||
UnitInput, LabeledInput, LabeledSelect, InputOrDisplay, LabelValue, Banner, Checkbox
|
||||
},
|
||||
|
||||
props: {
|
||||
@ -94,6 +96,30 @@ export default {
|
||||
return this.allPVCs.find( (P) => 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 {
|
||||
/>
|
||||
</InputOrDisplay>
|
||||
</div>
|
||||
<div
|
||||
v-if="isShareableCapable"
|
||||
data-testid="input-hee-shareable"
|
||||
class="col span-6"
|
||||
>
|
||||
<Checkbox
|
||||
v-model:value="value.shareable"
|
||||
class="check"
|
||||
type="checkbox"
|
||||
label-key="harvester.virtualMachine.volume.shareable.label"
|
||||
tooltip-key="harvester.virtualMachine.volume.shareable.tip"
|
||||
:mode="mode"
|
||||
@update:value="update"
|
||||
/>
|
||||
<Banner
|
||||
v-if="value.shareable"
|
||||
color="warning"
|
||||
:label="t('harvester.virtualMachine.volume.shareable.warning')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="value.volumeBackups"
|
||||
class="col span-6"
|
||||
|
||||
@ -798,6 +798,10 @@ harvester:
|
||||
setFirst: Set as root volume
|
||||
saveVolume: Update Volume
|
||||
encryption: Encryption
|
||||
shareable:
|
||||
label: Shareable
|
||||
tip: Allow multiple virtual machines to attach and write to this volume simultaneously.
|
||||
warning: Multiple virtual machines can write to this volume at the same time. The guest workloads must coordinate access to the shared block device, otherwise concurrent writes will corrupt the data. Workloads that fence through SCSI-3 Persistent Reservations (e.g. Windows Server Failover Clustering) also require persistent reservation support from the storage backend. Host cache and hypervisor disk locking are disabled, and backup or snapshot operations are rejected while the volume is shareable.
|
||||
vmImageVolumeTip: Disk size ({diskSize}) should greater than selected image virtual size ({imageVirtualSize})
|
||||
lockTooltip:
|
||||
all: All volumes are encrypted.
|
||||
|
||||
@ -618,6 +618,7 @@ export default {
|
||||
type,
|
||||
storageClassName,
|
||||
hotpluggable,
|
||||
shareable: DISK.shareable || false,
|
||||
volumeStatus,
|
||||
dataSource,
|
||||
namespace,
|
||||
@ -969,6 +970,14 @@ export default {
|
||||
const specDisks = this.spec?.template?.spec?.domain?.devices?.disks;
|
||||
const mergedDisks = this.mergeDeviceList(specDisks, disks);
|
||||
|
||||
// `shareable` is an attachment-level opt-in, remove the field
|
||||
// inherited from the old spec when the disk row no longer requests it
|
||||
mergedDisks.forEach((disk) => {
|
||||
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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user