feat: support advance option and creation from DataVolume (#776) (#777)

* feat: support advance option and creation from DataVolume

    - advance option would let user change the accessMode/volumeMode
    - creation from DataVolume could supports various scenario with
      3rd-party storage



* feat: add data migration action on volume page



* refactor: use show advanced options link instead of checkbox



* feat: add feature flag



* feat: add feature flag for dataMigration action



---------




(cherry picked from commit 566e79eda5c08e84e431d5e048e4bb581e7d9aaf)

Signed-off-by: Vicente Cheng <freeze.bilsted@gmail.com>
Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: freeze <1615081+Vicente-Cheng@users.noreply.github.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
This commit is contained in:
mergify[bot] 2026-03-26 17:33:01 +08:00 committed by GitHub
parent dc91be11f9
commit c12d4b5fba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 372 additions and 20 deletions

View File

@ -65,6 +65,7 @@ const FEATURE_FLAGS = {
'vGPUAsPCIDevice', 'vGPUAsPCIDevice',
'instanceManagerResourcesSetting', 'instanceManagerResourcesSetting',
'rwxNetworkSetting', 'rwxNetworkSetting',
'createPVCWithDataVolume'
], ],
}; };

View File

@ -80,4 +80,5 @@ export const HCI = {
VOLUME_SNAPSHOT_CLASS: 'cdi.harvesterhci.io/storageProfileVolumeSnapshotClass', VOLUME_SNAPSHOT_CLASS: 'cdi.harvesterhci.io/storageProfileVolumeSnapshotClass',
MAC_ADDRESS: 'harvesterhci.io/mac-address', MAC_ADDRESS: 'harvesterhci.io/mac-address',
NODE_UPGRADE_PAUSE_MAP: 'harvesterhci.io/node-upgrade-pause-map', NODE_UPGRADE_PAUSE_MAP: 'harvesterhci.io/node-upgrade-pause-map',
CDI_POPULATOR_KIND: 'cdi.kubevirt.io/storage.populator.kind',
}; };

View File

@ -41,3 +41,8 @@ export const VMIMPORT_SOURCE_KINDS = {
OPENSTACK: 'OpenstackSource', OPENSTACK: 'OpenstackSource',
OVA: 'OvaSource', OVA: 'OvaSource',
}; };
export const CDI_POPULATOR_KIND = {
VOLUME_IMPORT_SOURCE: 'VolumeImportSource',
VOLUME_CLONE_SOURCE: 'VolumeCloneSource',
};

View File

@ -0,0 +1,183 @@
<script>
import { mapGetters } from 'vuex';
import { STORAGE_CLASS } from '@shell/config/types';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { sortBy } from '@shell/utils/sort';
import { isInternalStorageClass } from '../utils/storage-class';
import { Card } from '@components/Card';
import { Banner } from '@components/Banner';
import { LabeledInput } from '@components/Form/LabeledInput';
import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
export default {
name: 'HarvesterDataMigrationDialog',
emits: ['close'],
components: {
AsyncButton, Banner, Card, LabeledInput, LabeledSelect
},
props: {
resources: {
type: Array,
required: true
}
},
async fetch() {
this.storageClasses = await this.$store.dispatch('harvester/findAll', { type: STORAGE_CLASS });
},
data() {
return {
targetVolumeName: '',
targetStorageClassName: '',
errors: [],
storageClasses: [],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
actionResource() {
return this.resources[0];
},
storageClassOptions() {
return sortBy(
this.storageClasses
.filter((sc) => !isInternalStorageClass(sc.metadata?.name))
.map((sc) => ({
label: sc.metadata?.name,
value: sc.metadata?.name
})),
'label'
);
},
disableSave() {
return !this.targetVolumeName || !this.targetStorageClassName;
},
},
methods: {
close() {
this.targetVolumeName = '';
this.targetStorageClassName = '';
this.errors = [];
this.$emit('close');
},
async apply(buttonDone) {
if (!this.actionResource) {
buttonDone(false);
return;
}
if (!this.targetVolumeName) {
const name = this.t('harvester.modal.dataMigration.fields.targetVolumeName.label');
this['errors'] = [this.t('validation.required', { key: name })];
buttonDone(false);
return;
}
if (!this.targetStorageClassName) {
const name = this.t('harvester.modal.dataMigration.fields.targetStorageClassName.label');
this['errors'] = [this.t('validation.required', { key: name })];
buttonDone(false);
return;
}
try {
await this.actionResource.doAction('dataMigration', {
targetVolumeName: this.targetVolumeName,
targetStorageClassName: this.targetStorageClassName
}, {}, false);
buttonDone(true);
this.close();
} catch (err) {
const error = err?.data || err;
this['errors'] = exceptionToErrorsArray(error);
buttonDone(false);
}
},
}
};
</script>
<template>
<Card :show-highlight-border="false">
<template #title>
{{ t('harvester.modal.dataMigration.title') }}
</template>
<template #body>
<LabeledInput
v-model:value="targetVolumeName"
:label="t('harvester.modal.dataMigration.fields.targetVolumeName.label')"
:placeholder="t('harvester.modal.dataMigration.fields.targetVolumeName.placeholder')"
class="mb-20"
required
/>
<LabeledSelect
v-model:value="targetStorageClassName"
:label="t('harvester.modal.dataMigration.fields.targetStorageClassName.label')"
:placeholder="t('harvester.modal.dataMigration.fields.targetStorageClassName.placeholder')"
:options="storageClassOptions"
required
/>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
</template>
<template
#actions
class="actions"
>
<div class="buttons">
<button
class="btn role-secondary mr-10"
@click="close"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="apply"
:disabled="disableSave"
@click="apply"
/>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.actions {
width: 100%;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>

View File

@ -9,8 +9,11 @@ import { LabeledInput } from '@components/Form/LabeledInput';
import NameNsDescription from '@shell/components/form/NameNsDescription'; import NameNsDescription from '@shell/components/form/NameNsDescription';
import Conditions from '@shell/components/form/Conditions'; import Conditions from '@shell/components/form/Conditions';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import { Checkbox } from '@components/Form/Checkbox';
import jsyaml from 'js-yaml';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { allHash } from '@shell/utils/promise'; import { allHash } from '@shell/utils/promise';
import { get } from '@shell/utils/object'; import { clone, get } from '@shell/utils/object';
import { STORAGE_CLASS, LONGHORN, PV } from '@shell/config/types'; import { STORAGE_CLASS, LONGHORN, PV } from '@shell/config/types';
import { sortBy } from '@shell/utils/sort'; import { sortBy } from '@shell/utils/sort';
import { saferDump } from '@shell/utils/create-yaml'; import { saferDump } from '@shell/utils/create-yaml';
@ -33,6 +36,7 @@ export default {
components: { components: {
Banner, Banner,
Checkbox,
Tab, Tab,
UnitInput, UnitInput,
CruResource, CruResource,
@ -90,6 +94,8 @@ export default {
source, source,
storage, storage,
imageId, imageId,
showAdvanced: false,
createWithDataVolume: false,
snapshots: [], snapshots: [],
images: [], images: [],
GIBIBYTE GIBIBYTE
@ -98,6 +104,24 @@ export default {
created() { created() {
this.registerBeforeHook(this.willSave, 'willSave'); this.registerBeforeHook(this.willSave, 'willSave');
if (this.mode === _CREATE) {
const originalSaveYaml = this.value.saveYaml?.bind(this.value);
this.value.saveYaml = async(yaml) => {
if (this.createWithDataVolume && this.isBlank) {
const parsed = jsyaml.load(yaml);
const dvObj = { ...parsed, type: 'cdi.kubevirt.io.datavolume' };
const dataVolume = await this.$store.dispatch('harvester/create', dvObj);
await dataVolume.save();
return dataVolume;
}
return originalSaveYaml(yaml);
};
}
}, },
computed: { computed: {
@ -135,6 +159,10 @@ export default {
return Object.values(VOLUME_MODE); return Object.values(VOLUME_MODE);
}, },
accessModeOptions() {
return ['ReadWriteOnce', 'ReadWriteMany', 'ReadOnlyMany'];
},
imageOption() { imageOption() {
return sortBy( return sortBy(
this.images this.images
@ -275,6 +303,10 @@ export default {
return this.$store.getters['harvester-common/getFeatureEnabled']('lhV2VolExpansion'); return this.$store.getters['harvester-common/getFeatureEnabled']('lhV2VolExpansion');
}, },
isCreatePVCWithDataVolumeFeatureEnabled() {
return this.$store.getters['harvester-common/getFeatureEnabled']('createPVCWithDataVolume');
},
isResizeDisabled() { isResizeDisabled() {
return ( return (
!this.isLHV2VolExpansionFeatureEnabled && !this.isLHV2VolExpansionFeatureEnabled &&
@ -341,6 +373,58 @@ export default {
return readWriteOnce ? ['ReadWriteOnce'] : ['ReadWriteMany']; return readWriteOnce ? ['ReadWriteOnce'] : ['ReadWriteMany'];
}, },
buildDataVolumeObj() {
const storage = {
storageClassName: this.value.spec.storageClassName,
resources: { requests: { storage: this.storage } },
};
if (this.showAdvanced && this.value.spec.accessModes?.length > 0) {
storage.accessModes = this.value.spec.accessModes;
}
if (this.showAdvanced && this.value.spec.volumeMode) {
storage.volumeMode = this.value.spec.volumeMode;
}
return {
type: 'cdi.kubevirt.io.datavolume',
apiVersion: 'cdi.kubevirt.io/v1beta1',
kind: 'DataVolume',
metadata: {
name: this.value.metadata.name,
namespace: this.value.metadata.namespace,
annotations: this.value.metadata.annotations || {},
labels: this.value.metadata.labels || {},
},
spec: {
source: { blank: {} },
storage,
}
};
},
async save(buttonDone) {
if (this.isCreate && this.isBlank && this.createWithDataVolume) {
try {
this.update();
const dvObj = this.buildDataVolumeObj();
const dataVolume = await this.$store.dispatch('harvester/create', dvObj);
await dataVolume.save();
buttonDone(true);
this.done();
} catch (err) {
const error = err?.data || err;
this['errors'] = exceptionToErrorsArray(error);
buttonDone(false);
}
} else {
await CreateEditView.methods.save.call(this, buttonDone);
}
},
willSave() { willSave() {
this.update(); this.update();
}, },
@ -383,9 +467,17 @@ export default {
this.update(); this.update();
}, },
generateYaml() { generateYaml() {
const out = saferDump(this.value); this.update();
return out; if (this.isCreate && this.isBlank && this.createWithDataVolume) {
return saferDump(this.buildDataVolumeObj());
}
const plain = clone(this.value);
delete plain.saveYaml;
return saferDump(plain);
}, },
} }
}; };
@ -458,18 +550,6 @@ export default {
@update:value="update" @update:value="update"
/> />
<LabeledSelect
v-if="showVolumeMode"
v-model:value="value.spec.volumeMode"
:label="t('harvester.volume.volumeMode')"
:options="volumeModeOptions"
required
:disabled="!isCreate"
:mode="mode"
class="mb-20"
@update:value="update"
/>
<UnitInput <UnitInput
v-model:value="storage" v-model:value="storage"
:label="t('harvester.volume.size')" :label="t('harvester.volume.size')"
@ -490,6 +570,44 @@ export default {
> >
<span>{{ t('harvester.volume.longhorn.disableResize') }}</span> <span>{{ t('harvester.volume.longhorn.disableResize') }}</span>
</Banner> </Banner>
<div class="row mb-20">
<Checkbox
v-if="isCreate && isBlank && isCreatePVCWithDataVolumeFeatureEnabled"
v-model:value="createWithDataVolume"
:label="t('harvester.volume.createWithDataVolume')"
tooltip-key="harvester.volume.createWithDataVolumeTooltip"
/>
</div>
<a
v-if="isCreate && isCreatePVCWithDataVolumeFeatureEnabled"
role="button"
class="hand"
@click="showAdvanced = !showAdvanced"
>
{{ showAdvanced ? t('harvester.volume.hideAdvanced') : t('harvester.volume.showAdvanced') }}
</a>
<LabeledSelect
v-if="showAdvanced"
v-model:value="value.spec.accessModes"
:label="t('harvester.volume.accessModes')"
:options="accessModeOptions"
:multiple="true"
:mode="mode"
class="mb-20 mt-20"
@update:value="update"
/>
<LabeledSelect
v-if="showAdvanced"
v-model:value="value.spec.volumeMode"
:label="t('harvester.volume.volumeMode')"
:options="volumeModeOptions"
:mode="mode"
class="mb-20"
@update:value="update"
/>
</Tab> </Tab>
<Tab <Tab
v-if="!isCreate" v-if="!isCreate"

View File

@ -132,6 +132,15 @@ harvester:
targetVolume: targetVolume:
label: Target Volume label: Target Volume
placeholder: Select a target volume placeholder: Select a target volume
dataMigration:
title: Data Migration
fields:
targetVolumeName:
label: Target Volume Name
placeholder: Enter a target volume name
targetStorageClassName:
label: Target Storage Class
placeholder: Select a storage class
migration: migration:
failedMessage: Latest migration failed! failedMessage: Latest migration failed!
title: Migration title: Migration
@ -243,6 +252,7 @@ harvester:
abortMigration: Abort Migration abortMigration: Abort Migration
storageMigration: Storage Migration storageMigration: Storage Migration
cancelStorageMigration: Cancel Storage Migration cancelStorageMigration: Cancel Storage Migration
dataMigration: Data Migration
createTemplate: Generate Template createTemplate: Generate Template
enableMaintenance: Enable Maintenance Mode enableMaintenance: Enable Maintenance Mode
disableMaintenance: Disable Maintenance Mode disableMaintenance: Disable Maintenance Mode
@ -912,6 +922,11 @@ harvester:
conditions: Conditions conditions: Conditions
size: Size size: Size
volumeMode: Volume Mode volumeMode: Volume Mode
accessModes: Access Modes
createWithDataVolume: Create with DataVolume
createWithDataVolumeTooltip: Create Volume with Kubevirt/Containerized Data Importer way. It can fill accessMode/volumeMode automatically.
showAdvanced: Show Advanced Options
hideAdvanced: Hide Advanced Options
source: Source source: Source
kind: Kind kind: Kind
sourceOptions: sourceOptions:

View File

@ -70,8 +70,13 @@ export default {
return schema; return schema;
}, },
filterRows() { filterRows() {
// we only show the non golden image PVCs in the list return this.rows.filter((pvc) => {
return this.rows.filter((pvc) => !pvc?.isGoldenImageVolume); if (pvc?.isGoldenImageVolume || pvc?.isCDIPopulatorVolume) {
return false;
}
return true;
});
}, },
headers() { headers() {
return [ return [

View File

@ -9,6 +9,7 @@ import { colorForState } from '@shell/plugins/dashboard-store/resource-class';
import { HCI, VOLUME_SNAPSHOT } from '../../types'; import { HCI, VOLUME_SNAPSHOT } from '../../types';
import HarvesterResource from '../harvester'; import HarvesterResource from '../harvester';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../config/harvester'; import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../../config/harvester';
import { CDI_POPULATOR_KIND } from '../../config/types';
import { LVM_DRIVER } from './storage.k8s.io.storageclass'; import { LVM_DRIVER } from './storage.k8s.io.storageclass';
const DEGRADED_ERRORS = ['replica scheduling failed', 'precheck new replica failed']; const DEGRADED_ERRORS = ['replica scheduling failed', 'precheck new replica failed'];
@ -44,7 +45,7 @@ export default class HciPv extends HarvesterResource {
const exportImageAction = { const exportImageAction = {
action: 'exportImage', action: 'exportImage',
enabled: this.hasAction('export') && !this.isEncrypted, enabled: this.hasAction('export') && !this.isEncrypted,
icon: 'icon icon-copy', icon: 'icon icon-external-link',
label: this.t('harvester.action.exportImage') label: this.t('harvester.action.exportImage')
}; };
const takeSnapshotAction = { const takeSnapshotAction = {
@ -77,10 +78,23 @@ export default class HciPv extends HarvesterResource {
icon: 'icon icon-backup', icon: 'icon icon-backup',
label: this.t('harvester.action.cancelExpand') label: this.t('harvester.action.cancelExpand')
}, },
{
action: 'dataMigration',
enabled: this.hasAction('dataMigration') && this.createPVCWithDataVolumeFeatureEnabled,
icon: 'icon icon-copy',
label: this.t('harvester.action.dataMigration')
},
...out ...out
]; ];
} }
dataMigration(resources = this) {
this.$dispatch('promptModal', {
resources,
component: 'HarvesterDataMigrationDialog'
});
}
exportImage(resources = this) { exportImage(resources = this) {
this.$dispatch('promptModal', { this.$dispatch('promptModal', {
resources, resources,
@ -339,10 +353,20 @@ export default class HciPv extends HarvesterResource {
return this?.metadata?.annotations?.[HCI_ANNOTATIONS.GOLDEN_IMAGE] === 'true'; return this?.metadata?.annotations?.[HCI_ANNOTATIONS.GOLDEN_IMAGE] === 'true';
} }
get isCDIPopulatorVolume() {
const kind = this?.metadata?.annotations?.[HCI_ANNOTATIONS.CDI_POPULATOR_KIND];
return kind === CDI_POPULATOR_KIND.VOLUME_IMPORT_SOURCE || kind === CDI_POPULATOR_KIND.VOLUME_CLONE_SOURCE;
}
get thirdPartyStorageFeatureEnabled() { get thirdPartyStorageFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('thirdPartyStorage'); return this.$rootGetters['harvester-common/getFeatureEnabled']('thirdPartyStorage');
} }
get createPVCWithDataVolumeFeatureEnabled() {
return this.$rootGetters['harvester-common/getFeatureEnabled']('createPVCWithDataVolume');
}
get resourceExternalLink() { get resourceExternalLink() {
const host = window.location.host; const host = window.location.host;
const { params } = this.currentRoute(); const { params } = this.currentRoute();