mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 12:49:14 +00:00
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
(cherry picked from commit 9a935434c20408baeedb2686732046e24c944097)
Signed-off-by: Vicente Cheng <vicente.cheng@suse.com>
Co-authored-by: freeze <1615081+Vicente-Cheng@users.noreply.github.com>
282 lines
7.5 KiB
Vue
282 lines
7.5 KiB
Vue
<script>
|
|
import { exceptionToErrorsArray } from '@shell/utils/error';
|
|
import { sortBy } from '@shell/utils/sort';
|
|
import { mapGetters } from 'vuex';
|
|
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',
|
|
|
|
emits: ['close'],
|
|
|
|
components: {
|
|
AsyncButton, Card, LabeledInput, LabeledSelect, Banner, Checkbox
|
|
},
|
|
|
|
props: {
|
|
resources: {
|
|
type: Array,
|
|
required: true
|
|
}
|
|
},
|
|
|
|
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: '',
|
|
shareable: false,
|
|
errors: [],
|
|
allPVCs: [],
|
|
allStorageClasses: [],
|
|
allVMs: [],
|
|
};
|
|
},
|
|
|
|
computed: {
|
|
...mapGetters({ t: 'i18n/t' }),
|
|
|
|
PVCs() {
|
|
return this.allPVCs.filter((P) => this.actionResource.metadata.namespace === P.metadata.namespace) || [];
|
|
},
|
|
|
|
actionResource() {
|
|
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
|
|
.filter( (pvc) => {
|
|
if (!!pvc.metadata?.annotations?.[HCI_ANNOTATIONS.IMAGE_ID]) {
|
|
return false;
|
|
}
|
|
// we won't show golden image volume in the hot plug volume modal
|
|
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;
|
|
})
|
|
.map((pvc) => {
|
|
return {
|
|
label: pvc.metadata.name,
|
|
value: pvc.metadata.name
|
|
};
|
|
}),
|
|
'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 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', {
|
|
title: this.t('generic.notification.title.succeed'),
|
|
message: this.t('harvester.modal.hotplugVolume.success', { diskName: this.diskName, vm: this.actionResource.nameDisplay })
|
|
}, { root: true });
|
|
|
|
this.close();
|
|
buttonCb(true);
|
|
} else {
|
|
const error = [res?.data] || exceptionToErrorsArray(res);
|
|
|
|
this['errors'] = error;
|
|
buttonCb(false);
|
|
}
|
|
} catch (err) {
|
|
const error = err?.data || err;
|
|
const message = exceptionToErrorsArray(error);
|
|
|
|
this['errors'] = message;
|
|
buttonCb(false);
|
|
}
|
|
}
|
|
},
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<Card
|
|
ref="modal"
|
|
name="modal"
|
|
:show-highlight-border="false"
|
|
>
|
|
<template #title>
|
|
<h4
|
|
v-clean-html="t('harvester.modal.hotplugVolume.title')"
|
|
class="text-default-text"
|
|
/>
|
|
</template>
|
|
|
|
<template #body>
|
|
<LabeledInput
|
|
v-model:value="diskName"
|
|
:label="t('generic.name')"
|
|
required
|
|
/>
|
|
|
|
<LabeledSelect
|
|
v-model:value="volumeName"
|
|
:label="t('harvester.fields.volume')"
|
|
:options="volumeOption"
|
|
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"
|
|
: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="!diskName || !volumeName"
|
|
@click="save"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</Card>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.actions {
|
|
width: 100%;
|
|
}
|
|
|
|
.buttons {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
width: 100%;
|
|
}
|
|
</style>
|