feat: support for subnets and VPCs from UI (#374)

Signed-off-by: Andy Lee <andy.lee@suse.com>
This commit is contained in:
Andy Lee 2025-07-16 17:41:56 +08:00
parent bcabefe9f3
commit 9ca9fdb521
No known key found for this signature in database
GPG Key ID: EC774C32160918ED
17 changed files with 1371 additions and 61 deletions

View File

@ -42,7 +42,8 @@ const FEATURE_FLAGS = {
'vmMachineTypes',
'customSupportBundle',
'csiOnlineExpandValidation',
'vmNetworkMigration'
'vmNetworkMigration',
'kubeovnVpcSubnet'
]
};

View File

@ -424,6 +424,7 @@ export function init($plugin, store) {
[
HCI.CLUSTER_NETWORK,
HCI.NETWORK_ATTACHMENT,
HCI.VPC,
HCI.LB,
HCI.IP_POOL,
],
@ -550,6 +551,21 @@ export function init($plugin, store) {
exact: false
});
configureType(HCI.VPC, { hiddenNamespaceGroupButton: true, canYaml: false });
virtualType({
labelKey: 'harvester.vpc.label',
name: HCI.VPC,
namespaced: true,
weight: 187,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.VPC }
},
exact: false,
ifHaveType: HCI.VPC,
});
configureType(HCI.SNAPSHOT, {
isCreatable: false,
location: {

View File

@ -70,6 +70,7 @@ export const ADD_ONS = {
RANCHER_MONITORING: 'rancher-monitoring',
VM_IMPORT_CONTROLLER: 'vm-import-controller',
LVM_DRIVER: 'lvm.driver.harvesterhci.io',
KUBEOVN_OPERATOR: 'kubeovn-operator',
};
export const CSI_SECRETS = {

View File

@ -1 +1,2 @@
export const CLUSTER_NETWORK = 'clusterNetwork';
export const VPC = 'vpc';

View File

@ -104,3 +104,30 @@ export const HARVESTER_DESCRIPTION = {
...DESCRIPTION,
width: 150,
};
// The CIDR_BLOCK column in VPC list page
export const CIDR_BLOCK = {
name: 'cidrBlock',
labelKey: 'harvester.subnet.cidrBlock.label',
sort: 'cidrBlock',
value: 'spec.cidrBlock',
align: 'left',
};
// The Protocol column in VPC list page
export const PROTOCOL = {
name: 'protocol',
labelKey: 'harvester.subnet.protocol.label',
sort: 'protocol',
value: 'spec.protocol',
align: 'left',
};
// The Provider column in VPC list page
export const PROVIDER = {
name: 'provider',
labelKey: 'harvester.subnet.provider.label',
sort: 'provider',
value: 'spec.provider',
align: 'left',
};

View File

@ -5,10 +5,16 @@ export const BACKUP_TYPE = {
export const NETWORK_TYPE = {
L2VLAN: 'L2VlanNetwork',
UNTAGGED: 'UntaggedNetwork'
UNTAGGED: 'UntaggedNetwork',
OVERLAY: 'OverlayNetwork',
};
export const VOLUME_MODE = {
BLOCK: 'Block',
FILE_SYSTEM: 'Filesystem'
};
export const NETWORK_PROTOCOL = {
IPv4: 'IPv4',
IPv6: 'IPv6',
};

View File

@ -6,14 +6,13 @@ import { LabeledInput } from '@components/Form/LabeledInput';
import { RadioGroup } from '@components/Form/Radio';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { HCI as HCI_LABELS_ANNOTATIONS } from '@pkg/harvester/config/labels-annotations';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../types';
import { NETWORK_TYPE } from '../config/types';
const { L2VLAN, UNTAGGED } = NETWORK_TYPE;
const { L2VLAN, UNTAGGED, OVERLAY } = NETWORK_TYPE;
const AUTO = 'auto';
const MANUAL = 'manual';
@ -44,13 +43,10 @@ export default {
data() {
const config = JSON.parse(this.value.spec.config);
const annotations = this.value?.metadata?.annotations || {};
const layer3Network = JSON.parse(annotations[HCI_LABELS_ANNOTATIONS.NETWORK_ROUTE] || '{}');
if ((config.bridge || '').endsWith('-br')) {
config.bridge = config.bridge.slice(0, -3);
}
const type = this.value.vlanType || L2VLAN ;
return {
@ -78,6 +74,30 @@ export default {
},
computed: {
clusterBridge: {
get() {
if (!this.config.bridge) {
return '';
}
// remove -br suffix if exists
return this.config?.bridge?.endsWith('-br') ? this.config.bridge.slice(0, -3) : '';
},
set(neu) {
if (neu === '') {
this.config.bridge = '';
return;
}
if (!neu.endsWith('-br')) {
this.config.bridge = `${ neu }-br`;
} else {
this.config.bridge = neu;
}
}
},
modeOptions() {
return [{
label: this.t('harvester.network.layer3Network.mode.auto'),
@ -88,6 +108,14 @@ export default {
}];
},
kubeovnVpcSubnetSupport() {
return this.$store.getters['harvester-common/getFeatureEnabled']('kubeovnVpcSubnet');
},
longhornV2LVMSupport() {
return this.$store.getters['harvester-common/getFeatureEnabled']('longhornV2LVMSupport');
},
clusterNetworkOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const clusterNetworks = this.$store.getters[`${ inStore }/all`](HCI.CLUSTER_NETWORK) || [];
@ -103,8 +131,30 @@ export default {
});
},
networkType() {
return [L2VLAN, UNTAGGED];
networkTypes() {
const types = [L2VLAN, UNTAGGED];
if (this.kubeovnVpcSubnetSupport) {
types.push(OVERLAY);
}
return types;
},
isL2VlanNetwork() {
if (this.isView) {
return this.value.vlanType === L2VLAN;
}
return this.type === L2VLAN;
},
isOverlayNetwork() {
if (this.isView) {
return this.value.vlanType === OVERLAY;
}
return this.type === OVERLAY;
},
isUntaggedNetwork() {
@ -116,36 +166,54 @@ export default {
}
},
watch: {
type(newType) {
if (newType === OVERLAY) {
this.config.type = 'kube-ovn';
this.config.provider = `${ this.value.metadata.name }.${ this.value.metadata.namespace }.ovn`;
this.config.server_socket = '/run/openvswitch/kube-ovn-daemon.sock';
} else {
this.config.type = 'bridge';
this.config.promiscMode = true;
this.config.ipam = {};
this.config.bridge = '';
delete this.config.provider;
delete this.config.server_socket;
}
}
},
methods: {
async saveNetwork(buttonCb) {
const errors = [];
if (!this.config.vlan && !this.isUntaggedNetwork) {
errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('tableHeaders.networkVlan') }));
}
if (!this.config.bridge) {
errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('harvester.network.clusterNetwork.label') }));
}
if (this.layer3Network.mode === MANUAL) {
if (!this.layer3Network.gateway) {
errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('harvester.network.layer3Network.gateway.label') }));
if (this.isL2VlanNetwork || this.isUntaggedNetwork) {
if (!this.config.vlan && !this.isUntaggedNetwork) {
errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('tableHeaders.networkVlan') }));
}
if (!this.layer3Network.cidr) {
errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('harvester.network.layer3Network.cidr.label') }));
if (!this.config.bridge) {
errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('harvester.network.clusterNetwork.label') }));
}
if (this.layer3Network.mode === MANUAL) {
if (!this.layer3Network.gateway) {
errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('harvester.network.layer3Network.gateway.label') }));
}
if (!this.layer3Network.cidr) {
errors.push(this.$store.getters['i18n/t']('validation.required', { key: this.t('harvester.network.layer3Network.cidr.label') }));
}
}
if (errors.length > 0) {
buttonCb(false);
this.errors = errors;
return false;
}
this.value.setAnnotation(HCI_LABELS_ANNOTATIONS.NETWORK_ROUTE, JSON.stringify(this.layer3Network));
}
if (errors.length > 0) {
buttonCb(false);
this.errors = errors;
return false;
}
this.value.setAnnotation(HCI_LABELS_ANNOTATIONS.NETWORK_ROUTE, JSON.stringify(this.layer3Network));
await this.save(buttonCb);
},
@ -169,14 +237,19 @@ export default {
updateBeforeSave() {
this.config.name = this.value.metadata.name;
if (this.isOverlayNetwork) {
this.config.provider = `${ this.value.metadata.name }.${ this.value.metadata.namespace }.ovn`;
delete this.config.bridge;
delete this.config.promiscMode;
delete this.config.vlan;
delete this.config.ipam;
}
if (this.isUntaggedNetwork) {
delete this.config.vlan;
}
this.value.spec.config = JSON.stringify({
...this.config,
bridge: `${ this.config.bridge }-br`,
});
this.value.spec.config = JSON.stringify({ ...this.config });
},
}
};
@ -212,14 +285,15 @@ export default {
<LabeledSelect
v-model:value="type"
class="mb-20"
:options="networkType"
:options="networkTypes"
:mode="mode"
:disabled="isEdit"
:label="t('harvester.fields.type')"
required
/>
<LabeledInput
v-if="!isUntaggedNetwork"
v-if="isL2VlanNetwork"
v-model:value.number="config.vlan"
class="mb-20"
required
@ -229,25 +303,20 @@ export default {
:mode="mode"
@update:value="input"
/>
<div class="row">
<div
class="col span-12"
>
<LabeledSelect
v-model:value="config.bridge"
class="mb-20"
:label="t('harvester.network.clusterNetwork.label')"
required
:options="clusterNetworkOptions"
:mode="mode"
:placeholder="t('harvester.network.clusterNetwork.selectPlaceholder')"
/>
</div>
</div>
<LabeledSelect
v-if="!isOverlayNetwork"
v-model:value="clusterBridge"
class="mb-20"
:label="t('harvester.network.clusterNetwork.label')"
required
:disabled="isEdit"
:options="clusterNetworkOptions"
:mode="mode"
:placeholder="t('harvester.network.clusterNetwork.selectPlaceholder')"
/>
</Tab>
<Tab
v-if="!isUntaggedNetwork"
v-if="isL2VlanNetwork"
name="layer3Network"
:label="t('harvester.network.tabs.layer3Network')"
:weight="98"

View File

@ -0,0 +1,323 @@
<script>
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput';
import Tab from '@shell/components/Tabbed/Tab';
import { NETWORK_ATTACHMENT } from '@shell/config/types';
import Loading from '@shell/components/Loading';
import CreateEditView from '@shell/mixins/create-edit-view';
import { RadioGroup } from '@components/Form/Radio';
import { NETWORK_PROTOCOL, NETWORK_TYPE } from '@pkg/harvester/config/types';
import { set } from '@shell/utils/object';
import ArrayList from '@shell/components/form/ArrayList';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../../types';
import ResourceTabs from '@shell/components/form/ResourceTabs/index';
export default {
name: 'EditSubnet',
emits: ['update:value'],
components: {
CruResource,
LabeledInput,
LabeledSelect,
NameNsDescription,
Tab,
RadioGroup,
ArrayList,
ResourceTabs,
Loading,
},
mixins: [CreateEditView],
inheritAttrs: false,
created() {
const vpc = this.$route.query.vpc || '';
set(this.value, 'spec', this.value.spec || {
cidrBlock: '',
protocol: NETWORK_PROTOCOL.IPv4,
provider: '',
vpc,
gatewayIP: '',
excludeIps: [],
private: false
});
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = {
vpc: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC }),
nad: this.$store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT }),
};
await allHash(hash);
},
computed: {
showAllowSubnets() {
return this.value?.spec?.private === true;
},
doneLocationOverride() {
return this.value.doneOverride;
},
allowSubnetTooltip() {
return this.t('harvester.subnet.allowSubnet.tooltip', null, true);
},
excludeIPsTooltip() {
return this.t('harvester.subnet.excludeIPs.tooltip', null, true);
},
protocolOptions() {
return Object.values(NETWORK_PROTOCOL);
},
provider: {
get() {
const raw = this.value.spec.provider;
if (!raw) {
return '';
}
const vmNet = raw.split('.')[0] || '';
const ns = raw.split('.')[1] || '';
return `${ ns }/${ vmNet }`;
},
set(value) {
const ns = value.split('/')[0] || '';
const vmNet = value.split('/')[1] || '';
const provider = `${ vmNet }.${ ns }.ovn`;
set(this.value, 'spec.provider', provider);
}
},
providerOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const vmNets = this.$store.getters[`${ inStore }/all`](NETWORK_ATTACHMENT) || [];
return vmNets.filter((net) => net.vlanType === NETWORK_TYPE.OVERLAY).map((n) => ({
label: n.id,
value: n.id,
}));
},
vpcOptions() {
const inStore = this.$store.getters['currentProduct'].inStore;
const vpcs = this.$store.getters[`${ inStore }/all`](HCI.VPC) || [];
return vpcs.map((n) => ({
label: n.id,
value: n.id,
}));
}
},
methods: {
async saveSubnet(buttonCb) {
const errors = [];
const name = this.value?.metadata?.name;
try {
if (!name) {
errors.push(this.t('validation.required', { key: this.t('generic.name') }, true));
} else if (!this.value?.spec?.cidrBlock) {
errors.push(this.t('validation.required', { key: this.t('harvester.subnet.cidrBlock.label') }, true));
} else if (!this.value?.spec?.provider) {
errors.push(this.t('validation.required', { key: this.t('harvester.subnet.provider.label') }, true));
} else if (this.value.spec.excludeIps.includes('')) {
errors.push(this.t('harvester.validation.subnet.excludeIps'));
}
if (errors.length > 0) {
buttonCb(false);
this.errors = errors;
return false;
}
await this.value.save();
buttonCb(true);
this.done();
} catch (e) {
this.errors = [e];
buttonCb(false);
}
},
},
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<CruResource
v-else
:done-route="doneRoute"
:resource="value"
:mode="mode"
:apply-hooks="applyHooks"
:errors="errors"
@finish="saveSubnet"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:need-events="false"
:need-related="false"
:mode="mode"
:side-tabs="true"
>
<Tab
name="Basic"
:label="t('generic.basic')"
:weight="-1"
class="bordered-table"
>
<div class="row mt-10">
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.cidrBlock"
class="mb-20"
required
:placeholder="t('harvester.subnet.cidrBlock.placeholder')"
:label="t('harvester.subnet.cidrBlock.label')"
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.protocol"
:label="t('harvester.subnet.protocol.label')"
:options="protocolOptions"
required
:mode="mode"
/>
</div>
</div>
<div class="row mt-10">
<div class="col span-6">
<LabeledSelect
v-model:value="provider"
:label="t('harvester.subnet.provider.label')"
:options="providerOptions"
:tooltip="t('harvester.subnet.provider.tooltip')"
required
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.vpc"
:label="t('harvester.subnet.vpc.label')"
:options="vpcOptions"
required
:disabled="true"
:mode="mode"
/>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.gateway"
class="mb-20"
:placeholder="t('harvester.subnet.gateway.placeholder')"
:label="t('harvester.subnet.gateway.label')"
:mode="mode"
/>
</div>
</div>
<div class="row mt-20">
<div class="col span-6">
<RadioGroup
v-model:value="value.spec.private"
name="enabled"
:options="[true, false]"
:label="t('harvester.subnet.private.label')"
:labels="[t('generic.enabled'), t('generic.disabled')]"
:mode="mode"
tooltip-key="harvester.subnet.private.tooltip"
/>
</div>
</div>
<ArrayList
v-if="showAllowSubnets"
v-model:value="value.spec.allowSubnets"
:show-header="true"
class="mt-20"
:mode="mode"
:add-label="t('harvester.subnet.allowSubnet.addSubnet')"
>
<template #column-headers>
<div class="box">
<h3 class="key">
{{ t('harvester.subnet.allowSubnet.label') }}
<i
v-clean-tooltip="{content: allowSubnetTooltip, triggers: ['hover', 'touch', 'focus'] }"
v-stripped-aria-label="allowSubnetTooltip"
class="icon icon-info"
tabindex="0"
/>
</h3>
</div>
</template>
<template #columns="scope">
<div class="key">
<input
v-model="scope.row.value"
:placeholder="t('harvester.subnet.allowSubnet.placeholder')"
/>
</div>
</template>
</ArrayList>
<ArrayList
v-model:value="value.spec.excludeIps"
:show-header="true"
class="mt-20"
:mode="mode"
:add-label="t('harvester.setting.storageNetwork.exclude.addIp')"
>
<template #column-headers>
<div class="box">
<h3 class="key">
{{ t('harvester.setting.storageNetwork.exclude.label') }}
<i
v-clean-tooltip="{content: excludeIPsTooltip, triggers: ['hover', 'touch', 'focus'] }"
v-stripped-aria-label="excludeIPsTooltip"
class="icon icon-info"
tabindex="0"
/>
</h3>
</div>
</template>
<template #columns="scope">
<div class="key">
<input
v-model="scope.row.value"
:placeholder="t('harvester.setting.storageNetwork.exclude.placeholder')"
/>
</div>
</template>
</ArrayList>
</Tab>
</ResourceTabs>
</CruResource>
</template>

View File

@ -0,0 +1,162 @@
<script>
import debounce from 'lodash/debounce';
import { _EDIT, _VIEW } from '@shell/config/query-params';
import { removeAt } from '@shell/utils/array';
export default {
name: 'StaticRoutes',
emits: ['update:value'],
props: {
value: {
type: Array,
default: null,
},
mode: {
type: String,
default: _EDIT,
},
},
data() {
const rows = (this.value || []).map((row) => {
return {
cidr: row.cidr || '',
nextHopIP: row.nextHopIP || '',
};
});
return { rows };
},
computed: {
isView() {
return this.mode === _VIEW;
},
showAdd() {
return !this.isView;
},
showRemove() {
return !this.isView;
},
nextHopIPTooltip() {
return this.t('harvester.vpc.staticRoutes.nextHopIP.tooltip');
},
},
created() {
this.queueUpdate = debounce(this.update, 100);
},
methods: {
add() {
this.rows.push({
cidr: '',
nextHopIP: '',
});
this.queueUpdate();
},
remove(idx) {
removeAt(this.rows, idx);
this.queueUpdate();
},
update() {
if (this.isView) {
return;
}
this.$emit('update:value', this.rows);
}
},
};
</script>
<template>
<div>
<div
v-if="rows.length"
class="static-route-row"
>
<div
v-for="(row, idx) in rows"
:key="idx"
>
<div class="pool-headers cidr">
<span class="pool-cidr">
<t k="harvester.vpc.staticRoutes.cidr.label" />
</span>
<span class="pool-nextHopIP">
<t k="harvester.vpc.staticRoutes.nextHopIP.label" />
<i
v-clean-tooltip="{content: nextHopIPTooltip, triggers: ['hover', 'touch', 'focus'] }"
v-stripped-aria-label="nextHopIPTooltip"
class="icon icon-info"
tabindex="0"
/>
</span>
</div>
<div class="pool-row cidr">
<div class="pool-cidr">
<span v-if="isView">
{{ row.cidr }}
</span>
<input
v-else
v-model="row.cidr"
type="text"
:placeholder="t('harvester.vpc.staticRoutes.cidr.placeholder')"
@input="queueUpdate"
/>
</div>
<div class="pool-nextHopIP">
<span v-if="isView">
{{ row.nextHopIP }}
</span>
<input
v-else
v-model="row.nextHopIP"
type="text"
:placeholder="t('harvester.vpc.staticRoutes.nextHopIP.placeholder')"
@input="queueUpdate"
/>
</div>
<button
v-if="showRemove"
type="button"
class="btn role-link pl-0"
@click="remove(idx)"
>
<t k="generic.remove" />
</button>
</div>
</div>
</div>
<button
v-if="showAdd"
type="button"
class="btn role-tertiary add"
@click="add()"
>
<t k="generic.add" />
</button>
</div>
</template>
<style lang="scss" scoped>
.pool-headers, .pool-row {
display: grid;
grid-column-gap: $column-gutter;
margin-bottom: 10px;
align-items: center;
&.cidr {
grid-template-columns: 40%+$column-gutter 40%+$column-gutter 15%;
}
}
</style>

View File

@ -0,0 +1,187 @@
<script>
import debounce from 'lodash/debounce';
import { _EDIT, _VIEW } from '@shell/config/query-params';
import { removeAt } from '@shell/utils/array';
import { HCI } from '../../types';
import LabeledSelect from '@shell/components/form/LabeledSelect';
export default {
name: 'VpcPeerings',
emits: ['update:value'],
components: { LabeledSelect },
props: {
value: {
type: Array,
default: null,
},
mode: {
type: String,
default: _EDIT,
},
vpc: {
type: Object,
default: () => ({}),
},
},
async fetch() {
await this.$store.dispatch('harvester/findAll', { type: HCI.VPC });
},
data() {
const rows = (this.value || []).map((row) => {
return {
localConnectIP: row.localConnectIP || '',
remoteVpc: row.remoteVpc || '',
};
});
return { rows };
},
computed: {
isView() {
return this.mode === _VIEW;
},
isEdit() {
return this.mode === _EDIT;
},
showAdd() {
return !this.isView;
},
showRemove() {
return !this.isView;
},
remoteVpcOptions() {
const allVpcs = this.$store.getters['harvester/all'](HCI.VPC) || [];
// filter self vpc if editing
const vpcs = this.isEdit ? allVpcs.filter((v) => v.id !== this.vpc.id) : allVpcs;
return vpcs.map((n) => ({
label: n.id,
value: n.id,
}));
},
localConnectIPTooltip() {
return this.t('harvester.vpc.vpcPeerings.localConnectIP.tooltip');
},
},
created() {
this.queueUpdate = debounce(this.update, 100);
},
methods: {
add() {
this.rows.push({
localConnectIP: '',
remoteVpc: '',
});
this.queueUpdate();
},
remove(idx) {
removeAt(this.rows, idx);
this.queueUpdate();
},
update() {
if (this.isView) {
return;
}
this.$emit('update:value', this.rows);
}
},
};
</script>
<template>
<div>
<div
v-if="rows.length"
class="static-route-row"
>
<div
v-for="(row, idx) in rows"
:key="idx"
>
<div class="pool-headers localConnectIP">
<span class="pool-localConnectIP">
<t k="harvester.vpc.vpcPeerings.localConnectIP.label" />
<i
v-clean-tooltip="{content: localConnectIPTooltip, triggers: ['hover', 'touch', 'focus'] }"
v-stripped-aria-label="localConnectPTooltip"
class="icon icon-info"
tabindex="0"
/>
</span>
<span class="pool-remoteVpc">
<t k="harvester.vpc.vpcPeerings.remoteVpc.label" />
</span>
</div>
<div class="pool-row localConnectIP">
<div class="pool-localConnectIP">
<span v-if="isView">
{{ row.localConnectIP }}
</span>
<input
v-else
v-model="row.localConnectIP"
type="text"
:placeholder="t('harvester.vpc.vpcPeerings.localConnectIP.placeholder')"
@input="queueUpdate"
/>
</div>
<div class="pool-remoteVpc">
<span v-if="isView">
{{ row.remoteVpc }}
</span>
<LabeledSelect
v-model:value="row.remoteVpc"
:options="remoteVpcOptions"
:mode="mode"
@update:value="queueUpdate"
/>
</div>
<button
v-if="showRemove"
type="button"
class="btn role-link pl-0"
@click="remove(idx)"
>
<t k="generic.remove" />
</button>
</div>
</div>
</div>
<button
v-if="showAdd"
type="button"
class="btn role-tertiary add"
@click="add()"
>
<t k="generic.add" />
</button>
</div>
</template>
<style lang="scss" scoped>
.pool-headers, .pool-row {
display: grid;
grid-column-gap: $column-gutter;
margin-bottom: 10px;
align-items: center;
&.localConnectIP {
grid-template-columns: 40%+$column-gutter 40%+$column-gutter 15%;
}
}
</style>

View File

@ -0,0 +1,120 @@
<script>
import CruResource from '@shell/components/CruResource';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import Tab from '@shell/components/Tabbed/Tab';
import Loading from '@shell/components/Loading';
import CreateEditView from '@shell/mixins/create-edit-view';
import ResourceTabs from '@shell/components/form/ResourceTabs/index';
import StaticRoutes from './StaticRoutes';
import VpcPeerings from './VpcPeerings';
import { set } from '@shell/utils/object';
export default {
name: 'EditVPC',
emits: ['update:value'],
components: {
CruResource,
NameNsDescription,
Tab,
StaticRoutes,
ResourceTabs,
Loading,
VpcPeerings
},
mixins: [CreateEditView],
inheritAttrs: false,
data() {
set(this.value, 'spec', this.value.spec || {
staticRoutes: [],
vpcPeerings: [],
});
return { staticRoutes: [] };
},
methods: {
async saveVpc(buttonCb) {
const errors = [];
try {
const name = this.value?.metadata?.name;
if (!name) {
errors.push(this.t('validation.required', { key: this.t('generic.name') }, true));
}
if (errors.length > 0) {
buttonCb(false);
this.errors = errors;
return false;
}
await this.value.save();
buttonCb(true);
this.done();
} catch (e) {
this.errors = [e];
buttonCb(false);
}
},
},
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<CruResource
v-else
:done-route="doneRoute"
:resource="value"
:mode="mode"
:apply-hooks="applyHooks"
:errors="errors"
@finish="saveVpc"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:namespaced="false"
@update:value="$emit('update:value', $event)"
/>
<ResourceTabs
class="mt-15"
:mode="mode"
:side-tabs="true"
>
<Tab
name="staticRoutes"
:label="t('harvester.vpc.staticRoutes.label')"
:weight="-1"
class="bordered-table"
>
<StaticRoutes
v-model:value="value.spec.staticRoutes"
class="col span-12"
:mode="mode"
/>
</Tab>
<Tab
name="vpcPeerings"
:label="t('harvester.vpc.vpcPeerings.label')"
:weight="-2"
class="bordered-table"
>
<VpcPeerings
v-model:value="value.spec.vpcPeerings"
class="col span-12"
:mode="mode"
:vpc="value"
/>
</Tab>
</ResourceTabs>
</CruResource>
</template>

View File

@ -396,6 +396,8 @@ harvester:
ruleFileTip: 'The file you have chosen ends in an extension that we do not support. We only accept image files that end in .img, .iso, .qcow, .qcow2, .raw.'
hash:
sha512: 'Invalid SHA512 checksum.'
subnet:
excludeIps: 'Exclude IPs cannot be empty. Please remove or fill in the exclude IPs.'
dashboard:
label: Dashboard
@ -1015,6 +1017,59 @@ harvester:
progress: Restore in progress
complete: Restore completed
subnet:
cidrBlock:
tooltip: The subnet range in CIDR notation. Note that the CIDR blocks of different Subnets' within the same VPC cannot overlap.
label: CIDR Block
placeholder: e.g. 172.20.0.0/16
protocol:
label: Protocol
provider:
tooltip: Network provider for this Subnet. Must be one of the Virtual Machine Networks in OverlayNetwork type.
label: Provider
vpc:
label: Virtual Private Cloud
gateway:
label: Gateway IP
placeholder: e.g. 172.20.0.1
private:
label: Private Subnet
tooltip: Enable network isolation for this Subnet. When enabled, VMs can only communicate within this subnet, even if other subnets exist under the same VPC.
allowSubnet:
label: Allow Subnets
tooltip: You can specify certain subnets which can communicate with this subnet.
addSubnet: Add Allow Subnet
placeholder: e.g. 172.16.0.0/16
excludeIPs:
tooltip: The IP address list to reserve from automatic assignment. The gateway IP address is always excluded and will be automatically added to the list.
vpc:
noAddonEnabled:
prefix: The kubeovn-operator add-on is not enabled, click
middle: here
suffix: to enable the add-on to successfully create VPC and subnet.
label: Virtual Private Cloud
noChild: There is no subnet defined in this Virtual Private Cloud.
createSubnet: Create Subnet
staticRoutes:
label: Static Routes
cidr:
label: CIDR
placeholder: e.g. 172.16.0.0/16
nextHopIP:
tooltip: The localConnectIP on the other end of the peering VPC.
label: Next Hop IP
placeholder: e.g. 169.254.0.2
vpcPeerings:
label: VPC Peerings
localConnectIP:
tooltip: The designated IP address in CIDR notation of the VPC peering endpoint. Note that both IP addresses (for the VPC and the other VPC) should belong to the same subnet range and should not conflict with the cidrBlock in any existing subnets.
label: Local Connect IP
placeholder: e.g. 169.254.0.1/16
remoteVpc:
label: Remote VPC
network:
label: Virtual Machine Networks
tabs:
@ -1079,8 +1134,8 @@ harvester:
clusterNetwork: Cluster Network
vlan: VLAN ID
exclude:
label: Exclude
placeholder: e.g. 172.16.0.1/32
label: Exclude IPs
placeholder: e.g. 172.16.0.1
invalid: '"Exclude list" is invalid.'
addIp: Add Exclude IP
warning: 'WARNING: <br/> Any change to storage-network requires shutting down all virtual machines before applying this setting. <br/> Users have to ensure the cluster network is configured and VLAN Configuration will cover all nodes and ensure the network connectivity is working and expected in all nodes.'
@ -1706,6 +1761,11 @@ typeLabel:
one { Virtual Machine Backup }
other { Virtual Machine Backups }
}
kubeovn.io.vpc: |-
{count, plural,
one { Virtual Private Cloud }
other { Virtual Private Clouds }
}
harvesterhci.io.cloudtemplate: |-
{count, plural,
one { Cloud Configuration Template }

View File

@ -0,0 +1,285 @@
<script>
import Loading from '@shell/components/Loading';
import ResourceTable from '@shell/components/ResourceTable';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../config/harvester';
import { NAME, AGE, NAMESPACE, STATE } from '@shell/config/table-headers';
import { PROVIDER, PROTOCOL, CIDR_BLOCK } from '@pkg/harvester/config/table-headers';
import { HCI } from '../types';
import { VPC } from '../config/query-params';
import { ADD_ONS } from '../config/harvester-map';
import { allHash } from '@shell/utils/promise';
import MessageLink from '@shell/components/MessageLink';
import Banner from '@components/Banner/Banner.vue';
export default {
name: 'HarvesterVPC',
components: {
ResourceTable,
Loading,
MessageLink,
Banner
},
inheritAttrs: false,
props: {
schema: {
type: Object,
required: true,
}
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
const hash = await allHash({ addons: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.ADD_ONS }) });
this.enabledKubeOvnAddon = hash.addons.find((addon) => addon.name === ADD_ONS.KUBEOVN_OPERATOR)?.spec?.enabled === true;
if (this.enabledKubeOvnAddon) {
try {
await allHash({
rows: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SUBNET }),
vpcs: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.VPC }),
});
this.$store.dispatch('type-map/configureType', { match: HCI.SUBNET, isCreatable: this.enabledKubeOvnAddon });
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error fetching VPC and Subnet data:', e);
}
}
},
data() {
return {
HCI,
hasBothSchema: false,
enabledKubeOvnAddon: false,
to: `${ HCI.ADD_ONS }/kube-system/${ ADD_ONS.KUBEOVN_OPERATOR }?mode=edit`
};
},
computed: {
headers() {
return [
STATE,
NAME,
NAMESPACE,
CIDR_BLOCK,
PROTOCOL,
PROVIDER,
AGE
];
},
rows() {
return this.$store.getters[`harvester/all`](HCI.SUBNET) || [];
},
vpcWithoutSubnets() {
const vpcs = this.$store.getters[`harvester/all`](HCI.VPC) || [];
const out = vpcs.map((v) => {
const hasChild = v.status?.subnets?.length > 0 || false;
return {
...v,
hasChild
};
});
return out;
},
isSubnetCreatable() {
return (this.subnetSchema?.collectionMethods || []).includes('POST');
},
rowsWithFakeVpcs() {
const fakeRows = this.vpcWithoutSubnets.map((vpc) => {
return {
groupByLabel: vpc.id,
isFake: true,
mainRowKey: vpc.id,
nameDisplay: vpc.id,
groupByVpc: vpc.id,
availableActions: []
};
});
return [...this.rows, ...fakeRows];
},
createVPCLocation() {
const location = {
name: `${ HARVESTER_PRODUCT }-c-cluster-resource-create`,
params: {
product: HARVESTER_PRODUCT,
resource: HCI.VPC,
},
};
return location;
},
vpcSchema() {
return this.$store.getters[`harvester/schemaFor`](HCI.VPC);
},
subnetSchema() {
return this.$store.getters[`harvester/schemaFor`](HCI.SUBNET);
},
},
methods: {
groupLabel(group) {
return `${ this.t('harvester.vpc.label') }: ${ group.key }`;
},
slotName(vpc) {
return `main-row:${ vpc }`;
},
createSubnetLocation(group) {
const vpc = group.key;
const location = {
name: `${ HARVESTER_PRODUCT }-c-cluster-resource-create`,
params: {
product: HARVESTER_PRODUCT,
resource: HCI.SUBNET,
},
};
location.query = { [VPC]: vpc };
return location;
},
showVpcAction(event, group) {
const vpc = group.key;
const resource = this.$store.getters[`harvester/byId`](HCI.VPC, vpc);
this.$store.commit(`action-menu/show`, {
resources: [resource],
elem: event.target
});
},
},
typeDisplay() {
return this.t('harvester.vpc.label');
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<div v-else-if="!enabledKubeOvnAddon">
<Banner color="warning">
<MessageLink
:to="to"
prefix-label="harvester.vpc.noAddonEnabled.prefix"
middle-label="harvester.vpc.noAddonEnabled.middle"
suffix-label="harvester.vpc.noAddonEnabled.suffix"
/>
</Banner>
</div>
<div v-else>
<Masthead
:schema="vpcSchema"
:type-display="t('harvester.vpc.label')"
:resource="HCI.VPC"
:create-location="createVPCLocation"
:create-button-label="t('harvester.clusterNetwork.create.button.label')"
/>
<ResourceTable
:rows="rowsWithFakeVpcs"
:headers="headers"
:schema="subnetSchema"
:groupable="true"
group-by="groupByVpc"
>
<template #header-middle>
<div />
</template>
<template #group-by="{group}">
<div class="group-bar">
<div class="group-tab">
<span>
{{ groupLabel(group) }}
</span>
</div>
<div class="right">
<router-link
v-if="isSubnetCreatable"
class="btn btn-sm role-secondary mr-5"
:to="createSubnetLocation(group)"
>
{{ t('harvester.vpc.createSubnet') }}
</router-link>
<button
type="button"
class="btn btn-sm role-multi-action actions mr-10"
@click="showVpcAction($event, group)"
>
<i class="icon icon-actions" />
</button>
</div>
</div>
</template>
<template
v-for="(vpc) in vpcWithoutSubnets"
:key="vpc.id"
v-slot:[slotName(vpc.id)]
>
<tr
v-show="!vpc.hasChild"
class="main-row"
>
<td
class="empty text-center"
colspan="12"
>
{{ t('harvester.vpc.noChild') }}
</td>
</tr>
</template>
</ResourceTable>
</div>
</template>
<style lang="scss" scoped>
.state {
display: flex;
justify-content: space-between;
.icon-warning {
margin-top: 2px;
}
}
.group-bar {
display: flex;
flex-direction: row;
justify-content: space-between;
.right {
margin-top: 5px;
margin-bottom: 3px;
}
.group-tab {
&, &::after {
height: 50px;
}
&::after {
right: -20px;
}
SPAN {
color: var(--body-text) !important;
}
}
}
</style>

View File

@ -2,12 +2,10 @@
import Loading from '@shell/components/Loading';
import ResourceTable from '@shell/components/ResourceTable';
import Masthead from '@shell/components/ResourceList/Masthead';
import { allHash } from '@shell/utils/promise';
import { STATE, AGE, NAME } from '@shell/config/table-headers';
import { mapPref, GROUP_RESOURCES } from '@shell/store/prefs';
import { NODE } from '@shell/config/types';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../config/harvester';
import { CLUSTER_NETWORK } from '../config/query-params';
import { HCI } from '../types';
@ -38,7 +36,6 @@ export default {
computed: {
groupPreference: mapPref(GROUP_RESOURCES),
headers() {
return [
STATE,
@ -209,7 +206,6 @@ export default {
{{ groupLabel(group) }}
</span>
</div>
<div class="right">
<router-link
v-if="isClusterNetworkCreatable && group.key !== 'mgmt'"

View File

@ -0,0 +1,27 @@
import { clone } from '@shell/utils/object';
import { HCI } from '../types';
import HarvesterResource from './harvester';
export default class HciSubnet extends HarvesterResource {
get groupByVpc() {
return this.spec?.vpc || '';
}
get doneOverride() {
const detailLocation = clone(this.listLocation);
detailLocation.params.resource = HCI.VPC;
return detailLocation;
}
get parentLocationOverride() {
return {
...this.listLocation,
params: {
...this.listLocation.params,
resource: HCI.VPC
}
};
}
}

View File

@ -0,0 +1,27 @@
import { clone } from '@shell/utils/object';
import { HCI } from '../types';
import HarvesterResource from './harvester';
export default class HciVPC extends HarvesterResource {
get parentNameOverride() {
return this.$rootGetters['i18n/t'](`typeLabel."${ HCI.VPC }"`, { count: 1 })?.trim();
}
get doneOverride() {
const detailLocation = clone(this.listLocation);
detailLocation.params.resource = HCI.VPC;
return detailLocation;
}
get parentLocationOverride() {
return {
...this.listLocation,
params: {
...this.listLocation.params,
resource: HCI.VPC
}
};
}
}

View File

@ -16,6 +16,8 @@ export const HCI = {
RESTORE: 'harvesterhci.io.virtualmachinerestore',
NODE_NETWORK: 'network.harvesterhci.io.nodenetwork',
CLUSTER_NETWORK: 'network.harvesterhci.io.clusternetwork',
SUBNET: 'kubeovn.io.subnet',
VPC: 'kubeovn.io.vpc',
VM_IMAGE_DOWNLOADER: 'harvesterhci.io.virtualmachineimagedownloader',
SUPPORT_BUNDLE: 'harvesterhci.io.supportbundle',
NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition',