feat: add vm-migration-network setting (#395)

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
This commit is contained in:
Yiya Chen 2025-07-16 10:13:07 +08:00 committed by GitHub
parent 060105ead3
commit 4486f71c8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 291 additions and 1 deletions

View File

@ -0,0 +1,270 @@
<script>
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
import { RadioGroup } from '@components/Form/Radio';
import { Banner } from '@components/Banner';
import ArrayList from '@shell/components/form/ArrayList';
import { allHash } from '@shell/utils/promise';
import { isValidCIDR } from '@shell/utils/validators/cidr';
import { NODE } from '@shell/config/types';
import { _EDIT } from '@shell/config/query-params';
import { HCI } from '../../types';
const DEFAULT_NETWORK = {
clusterNetwork: '',
vlan: '',
range: '',
exclude: [],
};
export default {
name: 'VMMigrationNetwork',
components: {
LabeledInput,
LabeledSelect,
RadioGroup,
Banner,
ArrayList,
},
props: {
registerBeforeHook: {
type: Function,
required: true,
},
mode: {
type: String,
default: _EDIT,
},
value: {
type: Object,
default: () => ({ value: '' }),
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
try {
await allHash({
clusterNetworks: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.CLUSTER_NETWORK }),
vlanStatus: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VLAN_STATUS }),
nodes: this.$store.dispatch(`${ inStore }/findAll`, { type: NODE }),
});
this.fetchError = null;
} catch (e) {
console.error('Failed to fetch network data:', e); // eslint-disable-line no-console
this.fetchError = this.t('harvester.setting.vmMigrationNetwork.fetchError', { error: e.message || e }, true);
}
},
data() {
const { parsed, enabled, parseError } = this.parseInitialValue();
return {
enabled,
network: { ...DEFAULT_NETWORK, ...parsed },
fetchError: null,
parseError,
};
},
created() {
this.registerBeforeHook?.(this.willSave, 'willSave');
},
computed: {
allErrors() {
return [this.fetchError, this.parseError].filter(Boolean);
},
clusterNetworkOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const networks = this.$store.getters[`${ inStore }/all`](HCI.CLUSTER_NETWORK) || [];
return networks.map((net) => ({
label: net.isReadyForStorageNetwork ? net.id : `${ net.id } (${ this.t('generic.notReady') })`,
value: net.id,
disabled: !net.isReadyForStorageNetwork,
}));
},
disableEdit() {
return !!(this.fetchError || this.parseError);
},
},
methods: {
parseInitialValue() {
let parsed = {};
let enabled = false;
let parseError = null;
try {
if (typeof this.value.value === 'string' && this.value.value.trim()) {
parsed = JSON.parse(this.value.value);
enabled = true;
}
} catch (e) {
console.error('[VMMigrationNetwork] Failed to parse value:', e); // eslint-disable-line no-console
parseError = this.t('harvester.setting.vmMigrationNetwork.parseError', { error: e.message }, true);
}
if (!Array.isArray(parsed.exclude)) {
parsed.exclude = [];
}
return {
parsed, enabled, parseError
};
},
clearErrors() {
this.fetchError = null;
this.parseError = null;
},
inputVlan(val) {
this.network.vlan = val ? Math.min(4094, Math.max(1, Number(val))) : '';
this.update();
},
useDefault() {
this.network = { ...DEFAULT_NETWORK };
this.value.value = '';
this.enabled = false;
this.clearErrors();
},
update() {
try {
this.value.value = this.enabled ? JSON.stringify({
...this.network,
exclude: (this.network.exclude || []).filter((e) => !!e?.trim()),
}) : '';
} catch (e) {
console.error('Failed to stringify network config:', e); // eslint-disable-line no-console
this.value.value = '';
}
},
validateInputs() {
const errors = [];
if (!this.network.clusterNetwork) {
errors.push(this.t('validation.required', { key: this.t('harvester.setting.vmMigrationNetwork.clusterNetwork') }, true));
}
if (!this.network.range) {
errors.push(this.t('validation.required', { key: this.t('harvester.setting.vmMigrationNetwork.range.label') }, true));
} else if (!isValidCIDR(this.network.range)) {
errors.push(this.t('harvester.setting.vmMigrationNetwork.range.invalid', null, true));
}
if (this.network.vlan === '') {
errors.push(this.t('validation.required', { key: this.t('harvester.setting.vmMigrationNetwork.vlan') }, true));
} else {
const vlan = Number(this.network.vlan);
if (isNaN(vlan) || vlan < 1 || vlan > 4094) {
errors.push(this.t('validation.between', {
key: this.t('harvester.setting.vmMigrationNetwork.vlan'),
min: 1,
max: 4094,
}, true));
}
}
for (const cidr of this.network.exclude || []) {
if (cidr && !isValidCIDR(cidr)) {
errors.push(this.t('harvester.setting.storageNetwork.exclude.invalid', { value: cidr }, true));
}
}
return errors;
},
async willSave() {
if (!this.enabled) {
this.useDefault();
return Promise.resolve();
}
this.update();
const errors = this.validateInputs();
return errors.length ? Promise.reject(errors) : Promise.resolve();
},
},
};
</script>
<template>
<div>
<Banner
v-for="(errorMsg, index) in allErrors"
:key="index"
color="error"
>
{{ errorMsg }}
</Banner>
<RadioGroup
v-model:value="enabled"
class="mb-20"
name="enableMigrationNetwork"
:options="[true, false]"
:labels="[t('generic.enabled'), t('generic.disabled')]"
@update:value="update"
/>
<template v-if="enabled">
<LabeledSelect
v-model:value="network.clusterNetwork"
required
label-key="harvester.setting.vmMigrationNetwork.clusterNetwork"
class="mb-20"
:mode="mode"
:options="clusterNetworkOptions"
:disabled="disableEdit"
@update:value="update"
/>
<LabeledInput
v-model:value.number="network.vlan"
required
type="number"
class="mb-20"
:min="1"
:max="4094"
:mode="mode"
placeholder="e.g. 1 - 4094"
label-key="harvester.setting.vmMigrationNetwork.vlan"
:disabled="disableEdit"
@update:value="inputVlan"
/>
<LabeledInput
v-model:value="network.range"
required
class="mb-5"
:mode="mode"
:placeholder="t('harvester.setting.vmMigrationNetwork.range.placeholder')"
label-key="harvester.setting.vmMigrationNetwork.range.label"
:disabled="disableEdit"
@update:value="update"
/>
<ArrayList
v-model:value="network.exclude"
:show-header="true"
:default-add-value="''"
:mode="mode"
:add-disabled="disableEdit"
:add-label="t('harvester.setting.vmMigrationNetwork.exclude.addButton')"
:value-label="t('harvester.setting.vmMigrationNetwork.exclude.label')"
:value-placeholder="t('harvester.setting.storageNetwork.exclude.placeholder')"
@update:value="update"
/>
</template>
</div>
</template>

View File

@ -41,7 +41,8 @@ const FEATURE_FLAGS = {
'v1.6.0': [
'vmMachineTypes',
'customSupportBundle',
'csiOnlineExpandValidation'
'csiOnlineExpandValidation',
'vmNetworkMigration'
]
};

View File

@ -36,6 +36,7 @@ export const HCI_SETTING = {
LONGHORN_V2_DATA_ENGINE_ENABLED: 'longhorn-v2-data-engine-enabled',
ADDITIONAL_GUEST_MEMORY_OVERHEAD_RATIO: 'additional-guest-memory-overhead-ratio',
UPGRADE_CONFIG: 'upgrade-config',
VM_MIGRATION_NETWORK: 'vm-migration-network',
};
export const HCI_ALLOWED_SETTINGS = {
@ -107,6 +108,9 @@ export const HCI_ALLOWED_SETTINGS = {
featureFlag: 'upgradeConfigSetting',
docPath: 'UPGRADE_CONFIG_URL'
},
[HCI_SETTING.VM_MIGRATION_NETWORK]: {
kind: 'json', from: 'import', canReset: true, featureFlag: 'vmNetworkMigration',
},
};
export const HCI_SINGLE_CLUSTER_ALLOWED_SETTING = {

View File

@ -1087,6 +1087,20 @@ harvester:
tip: 'Specify an IP range in the IPv4 CIDR format. <code>Number of IPs Required = Number of Nodes * 2 + Number of Disks * 2 + Number of Images to Download/Upload </code>. For more information about storage network settings, see the <a href="{url}" target="_blank">documentation</a>.'
vmForceDeletionPolicy:
period: Period
vmMigrationNetwork:
parseError: "Failed to parse existing configuration."
fetchError: "Failed to load required network resources: {error}. Please refresh the page or try again later."
clusterNetwork: Cluster Network
vlan: VLAN ID
range:
placeholder: e.g. 172.16.0.0/24
label: IP Range
invalid: '"Range" is invalid.'
exclude:
label: Excluded IPs
placeholder: e.g. 172.16.0.1/32
invalid: '"Exclude list" is invalid.'
addButton: Add Exclude IP
ratio : Ratio
autoRotateRKE2Certs:
expiringInHours: Expiring in
@ -1634,6 +1648,7 @@ advancedSettings:
'harv-longhorn-v2-data-engine-enabled': 'Enable the Longhorn V2 data engine. Default is false. <ul><li>Changing this setting will restart RKE2 on all nodes. This will not affect running VM workloads.</li><li>If you see "not enough hugepages-2Mi capacity" errors when enabling this setting, wait a minute for the error to clear. If the error remains, reboot the affected node.</li></ul>'
'harv-additional-guest-memory-overhead-ratio': 'The ratio for kubevirt to adjust the VM overhead memory. The value could be zero, empty value or floating number between 1.0 and 10.0, default to 1.5.'
'harv-upgrade-config': 'Configure image preloading and VM restore options for upgrades. See related fields in <a href="{url}" target="_blank" rel="noopener">settings/upgrade-config</a>'
'harv-vm-migration-network': 'Segregated network for VM migration traffic.'
typeLabel:
kubevirt.io.virtualmachine: |-