From 74172b00ebbc696309507d0b5369bf4ea9ab2009 Mon Sep 17 00:00:00 2001 From: Ugo Palatucci Date: Mon, 7 Apr 2025 13:17:45 +0200 Subject: [PATCH 1/2] CNV-52160: UDN with topology selector --- .../en/plugin__networking-console-plugin.json | 3 + src/utils/resources/udns/constants.ts | 4 + src/utils/resources/udns/types/types.ts | 7 +- .../udns/list/components/SubnetsInput.tsx | 62 ++++++++ src/views/udns/list/components/Topology.tsx | 130 +++++++++++++++ .../UserDefinedNetworkCreateForm.tsx | 46 +----- .../UserDefinedNetworkCreateModal.tsx | 67 +++++--- src/views/udns/list/components/utils.ts | 150 ++++++++++++++++-- 8 files changed, 397 insertions(+), 72 deletions(-) create mode 100644 src/views/udns/list/components/SubnetsInput.tsx create mode 100644 src/views/udns/list/components/Topology.tsx diff --git a/locales/en/plugin__networking-console-plugin.json b/locales/en/plugin__networking-console-plugin.json index 34b41744..558a0afb 100644 --- a/locales/en/plugin__networking-console-plugin.json +++ b/locales/en/plugin__networking-console-plugin.json @@ -196,6 +196,8 @@ "key is the label key that the selector applies": "key is the label key that the selector applies", "Kind": "Kind", "Labels": "Labels", + "Layer 2": "Layer 2", + "Layer 3": "Layer 3", "Layer2 topology creates one logical switch shared by all nodes.": "Layer2 topology creates one logical switch shared by all nodes.", "Layer3 topology creates a layer 2 segment per node, each with a different subnet. Layer 3 routing is used to interconnect node subnets.": "Layer3 topology creates a layer 2 segment per node, each with a different subnet. Layer 3 routing is used to interconnect node subnets.", "Learn how to use NetworkAttachmentDefinitions": "Learn how to use NetworkAttachmentDefinitions", @@ -203,6 +205,7 @@ "Learn more about working with projects": "Learn more about working with projects", "List of pods": "List of pods", "List of pods matching": "List of pods matching", + "Localnet": "Localnet", "Location": "Location", "Location of the resource that backs the service": "Location of the resource that backs the service", "MAC spoof check": "MAC spoof check", diff --git a/src/utils/resources/udns/constants.ts b/src/utils/resources/udns/constants.ts index 77737891..a6845bf3 100644 --- a/src/utils/resources/udns/constants.ts +++ b/src/utils/resources/udns/constants.ts @@ -1,3 +1,7 @@ export const PROJECT_LABEL_FOR_MATCH_EXPRESSION = 'kubernetes.io/metadata.name'; export const FIXED_PRIMARY_UDN_NAME = 'primary-udn'; + +export const LOCALNET_TOPOLOGY = 'Localnet'; +export const LAYER2_TOPOLOGY = 'Layer2'; +export const LAYER3_TOPOLOGY = 'Layer3'; diff --git a/src/utils/resources/udns/types/types.ts b/src/utils/resources/udns/types/types.ts index 8cf68d83..fcce4331 100644 --- a/src/utils/resources/udns/types/types.ts +++ b/src/utils/resources/udns/types/types.ts @@ -29,7 +29,7 @@ export type UserDefinedNetworkSubnet = string | UserDefinedNetworkLayer3Subnet; export type UserDefinedNetworkLayer3 = { joinSubnets?: string[]; mtu?: number; - role: string; + role: UserDefinedNetworkRole; subnets?: UserDefinedNetworkLayer3Subnet[]; }; @@ -41,6 +41,11 @@ export type ClusterUserDefinedNetworkSpec = { export type UserDefinedNetworkSpec = { layer2?: UserDefinedNetworkLayer2; layer3?: UserDefinedNetworkLayer3; + localnet?: { + physicalNetworkName: string; + role: UserDefinedNetworkRole; + subnets?: UserDefinedNetworkSubnet[]; + }; topology: string; }; diff --git a/src/views/udns/list/components/SubnetsInput.tsx b/src/views/udns/list/components/SubnetsInput.tsx new file mode 100644 index 00000000..0413df81 --- /dev/null +++ b/src/views/udns/list/components/SubnetsInput.tsx @@ -0,0 +1,62 @@ +import React, { FC } from 'react'; +import { FieldPath, useFormContext, useWatch } from 'react-hook-form'; + +import { FormGroup, TextInput } from '@patternfly/react-core'; +import SubnetCIRDHelperText from '@utils/components/SubnetCIRDHelperText/SubnetCIRDHelperText'; +import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; +import { + UserDefinedNetworkLayer3Subnet, + UserDefinedNetworkSpec, +} from '@utils/resources/udns/types'; + +import { UDNForm } from './constants'; +import { getSubnetFields, getSubnetsFromNetworkSpec } from './utils'; + +type SubnetsInputProps = { + isClusterUDN?: boolean; +}; + +const SubnetsInput: FC = ({ isClusterUDN }) => { + const { t } = useNetworkingTranslation(); + + const { setValue } = useFormContext(); + const networkSpecPath = isClusterUDN ? 'spec.network' : 'spec'; + + const networkSpec: UserDefinedNetworkSpec = useWatch({ + name: networkSpecPath, + }); + + const subnets = getSubnetsFromNetworkSpec(networkSpec); + + const subnetsText = networkSpec.layer3 + ? (subnets as UserDefinedNetworkLayer3Subnet[]).map((subnet) => subnet.cidr).join(',') + : subnets?.join(','); + + return ( + + { + const subnetField = getSubnetFields(networkSpec, isClusterUDN); + + const newSubnet = networkSpec.layer3 + ? newValue.split(',').map((subnet) => ({ cidr: subnet })) + : newValue.split(','); + + setValue(subnetField as FieldPath, newSubnet, { + shouldValidate: true, + }); + }} + type="text" + value={subnetsText} + /> + + + ); +}; + +export default SubnetsInput; diff --git a/src/views/udns/list/components/Topology.tsx b/src/views/udns/list/components/Topology.tsx new file mode 100644 index 00000000..fb204eb0 --- /dev/null +++ b/src/views/udns/list/components/Topology.tsx @@ -0,0 +1,130 @@ +import React, { FC, Ref, useState } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import { + DropdownItem, + Flex, + FlexItem, + FormGroup, + MenuToggle, + MenuToggleElement, + Radio, + Select, + TextInput, +} from '@patternfly/react-core'; +import FormGroupHelperText from '@utils/components/FormGroupHelperText/FormGroupHelperText'; +import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; +import { LOCALNET_TOPOLOGY } from '@utils/resources/udns/constants'; +import { UserDefinedNetworkRole, UserDefinedNetworkSpec } from '@utils/resources/udns/types'; + +import { UDNForm } from './constants'; +import { createNetworkSpecFromRole, createNetworkSpecFromTopology, getTopology } from './utils'; + +type TopologyProps = { + isClusterUDN: boolean; +}; + +const Topology: FC = ({ isClusterUDN }) => { + const { t } = useNetworkingTranslation(); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const { register, setValue } = useFormContext(); + + const networkSpecPath = isClusterUDN ? 'spec.network' : 'spec'; + + const networkSpec: UserDefinedNetworkSpec = useWatch({ + name: networkSpecPath, + }); + + const topology = getTopology(networkSpec); + + const isPrimary = + networkSpec?.layer2?.role === UserDefinedNetworkRole.Primary || + networkSpec?.layer3?.role === UserDefinedNetworkRole.Primary; + + const onChangeRole = (role: UserDefinedNetworkRole) => { + setValue(networkSpecPath, createNetworkSpecFromRole(networkSpec, role)); + }; + + return ( + <> + + + + onChangeRole(UserDefinedNetworkRole.Primary)} + > + + + onChangeRole(UserDefinedNetworkRole.Secondary)} + > + + + + + + + + {topology === LOCALNET_TOPOLOGY && ( + + + + + {t( + 'The name of the physical network. This attribute must match the value of the spec.desiredState.ovn.bridge-mappings.localnet field of the NodeNetworkConfigurationPolicy object that defines the OVS bridge mapping. ', + )} + + + )} + + ); +}; + +export default Topology; diff --git a/src/views/udns/list/components/UserDefinedNetworkCreateForm.tsx b/src/views/udns/list/components/UserDefinedNetworkCreateForm.tsx index 360c5e2b..425a4846 100644 --- a/src/views/udns/list/components/UserDefinedNetworkCreateForm.tsx +++ b/src/views/udns/list/components/UserDefinedNetworkCreateForm.tsx @@ -1,30 +1,27 @@ import React, { FC, FormEventHandler } from 'react'; -import { Controller, useFormContext } from 'react-hook-form'; +import { useFormContext } from 'react-hook-form'; -import { Alert, AlertVariant, Content, Form, FormGroup, TextInput } from '@patternfly/react-core'; -import SubnetCIRDHelperText from '@utils/components/SubnetCIRDHelperText/SubnetCIRDHelperText'; +import { Content, Form, FormGroup, TextInput } from '@patternfly/react-core'; import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; import ClusterUDNNamespaceSelector from './ClusterUDNNamespaceSelector'; import { UDNForm } from './constants'; import SelectProject from './SelectProject'; +import SubnetsInput from './SubnetsInput'; +import Topology from './Topology'; type UserDefinedNetworkCreateFormProps = { - error: Error; isClusterUDN?: boolean; onSubmit: FormEventHandler; }; const UserDefinedNetworkCreateForm: FC = ({ - error, isClusterUDN, onSubmit, }) => { const { t } = useNetworkingTranslation(); - const { control, register, setValue } = useFormContext(); - - const subnetField = isClusterUDN ? 'spec.network.layer2.subnets' : 'spec.layer2.subnets'; + const { register } = useFormContext(); return (
@@ -48,39 +45,10 @@ const UserDefinedNetworkCreateForm: FC = ({ )} - - ( - - setValue(subnetField, newValue.split(','), { - shouldValidate: true, - }) - } - type="text" - value={value?.join(',')} - /> - )} - rules={{ required: true }} - /> - - - + + {isClusterUDN && } - - {error && ( - - {error?.message} - - )} ); }; diff --git a/src/views/udns/list/components/UserDefinedNetworkCreateModal.tsx b/src/views/udns/list/components/UserDefinedNetworkCreateModal.tsx index d07b779a..1c1e3154 100644 --- a/src/views/udns/list/components/UserDefinedNetworkCreateModal.tsx +++ b/src/views/udns/list/components/UserDefinedNetworkCreateModal.tsx @@ -4,6 +4,11 @@ import { useNavigate } from 'react-router-dom-v5-compat'; import { k8sCreate, useActiveNamespace } from '@openshift-console/dynamic-plugin-sdk'; import { + ActionList, + ActionListGroup, + ActionListItem, + Alert, + AlertVariant, Button, ButtonVariant, Modal, @@ -11,6 +16,8 @@ import { ModalFooter, ModalHeader, ModalVariant, + Stack, + StackItem, } from '@patternfly/react-core'; import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; import { ClusterUserDefinedNetworkModel, UserDefinedNetworkModel } from '@utils/models'; @@ -79,33 +86,51 @@ const UserDefinedNetworkCreateModal: FC = ({ - - + + {error && ( + + + {error?.message} + + + )} + + + + + + + + + + + + + ); diff --git a/src/views/udns/list/components/utils.ts b/src/views/udns/list/components/utils.ts index 9232d68d..541bfaca 100644 --- a/src/views/udns/list/components/utils.ts +++ b/src/views/udns/list/components/utils.ts @@ -1,11 +1,17 @@ import { K8sResourceCommon, MatchLabels } from '@openshift-console/dynamic-plugin-sdk'; import { ALL_NAMESPACES_KEY, DEFAULT_NAMESPACE } from '@utils/constants'; import { ClusterUserDefinedNetworkModel, UserDefinedNetworkModel } from '@utils/models'; -import { FIXED_PRIMARY_UDN_NAME } from '@utils/resources/udns/constants'; +import { + FIXED_PRIMARY_UDN_NAME, + LAYER2_TOPOLOGY, + LAYER3_TOPOLOGY, + LOCALNET_TOPOLOGY, +} from '@utils/resources/udns/constants'; import { ClusterUserDefinedNetworkKind, UserDefinedNetworkKind, UserDefinedNetworkRole, + UserDefinedNetworkSpec, } from '@utils/resources/udns/types'; import { generateName, isEmpty } from '@utils/utils'; @@ -16,6 +22,12 @@ export const match = (resource: K8sResourceCommon, matchLabels: MatchLabels) => ([key, value]) => resource?.metadata?.labels?.[key] === value, ); +const defaultConfiguration = { + ipam: { lifecycle: 'Persistent' }, + role: UserDefinedNetworkRole.Primary, + subnets: [''], +}; + export const createUDN = (namespace: string): UserDefinedNetworkKind => ({ apiVersion: `${UserDefinedNetworkModel.apiGroup}/${UserDefinedNetworkModel.apiVersion}`, kind: UserDefinedNetworkModel.kind, @@ -24,11 +36,7 @@ export const createUDN = (namespace: string): UserDefinedNetworkKind => ({ namespace, }, spec: { - layer2: { - ipam: { lifecycle: 'Persistent' }, - role: UserDefinedNetworkRole.Primary, - subnets: [''], - }, + layer2: defaultConfiguration, topology: 'Layer2', }, }); @@ -42,11 +50,7 @@ export const createClusterUDN = (name: string): ClusterUserDefinedNetworkKind => spec: { namespaceSelector: { matchExpressions: [] }, network: { - layer2: { - ipam: { lifecycle: 'Persistent' }, - role: UserDefinedNetworkRole.Primary, - subnets: [''], - }, + layer2: defaultConfiguration, topology: 'Layer2', }, }, @@ -58,10 +62,134 @@ export const getDefaultUDN = (isClusterUDN: boolean, namespace: string): UDNForm : createUDN(namespace === ALL_NAMESPACES_KEY ? DEFAULT_NAMESPACE : namespace); }; +export const getSubnetsFromNetworkSpec = (networkSpec: UserDefinedNetworkSpec) => + networkSpec?.layer2?.subnets || + networkSpec?.layer3?.subnets || + networkSpec?.localnet?.subnets || + networkSpec?.layer2?.subnets || + networkSpec?.layer3?.subnets || + networkSpec?.localnet?.subnets; + +export const getSubnetFields = (networkSpec: UserDefinedNetworkSpec, isClusterUDN) => { + const topology = getTopology(networkSpec); + + return isClusterUDN + ? `spec.network.${topology.toLowerCase()}.subnets` + : `spec.${topology.toLowerCase()}.subnets`; +}; + export const isUDNValid = (udn: UDNForm): boolean => { const clusterUDNConnected = !isEmpty(udn?.spec?.namespaceSelector?.matchExpressions) || !isEmpty(udn?.spec?.namespaceSelector?.matchLabels); + const subnets = getSubnetsFromNetworkSpec(udn?.spec?.network || udn?.spec); + + if (isEmpty(subnets) || isEmpty(subnets?.[0])) return false; + return !isEmpty(udn?.metadata?.namespace) || clusterUDNConnected; }; + +export const getRolePath = (networkSpec: UserDefinedNetworkSpec, isClusterUDN: boolean) => { + if (isClusterUDN) { + return !isEmpty(networkSpec?.layer2) ? 'spec.network.layer2.role' : 'spec.network.layer3.role'; + } + return !isEmpty(networkSpec?.layer2) ? 'spec.layer2.role' : 'spec.layer3.role'; +}; + +export const getTopology = (networkSpec: UserDefinedNetworkSpec) => { + if (!isEmpty(networkSpec?.layer2)) return LAYER2_TOPOLOGY; + if (!isEmpty(networkSpec?.layer3)) return LAYER3_TOPOLOGY; + if (!isEmpty(networkSpec?.localnet)) return LOCALNET_TOPOLOGY; +}; + +const convertSubnetsFromLayer3 = ( + layer3NetworkSpec: UserDefinedNetworkSpec, +): UserDefinedNetworkSpec['layer2']['subnets'] | UserDefinedNetworkSpec['localnet']['subnets'] => + layer3NetworkSpec.layer3.subnets.map((subnet) => subnet.cidr); + +const convertToLayer3Subnets = ( + networkSpec: UserDefinedNetworkSpec, +): UserDefinedNetworkSpec['layer3']['subnets'] => + (networkSpec.layer2 || networkSpec.localnet).subnets.map((subnet) => ({ cidr: subnet })); + +export const createNetworkSpecFromTopology = ( + newTopology: string, + currentNetworkSpec: UserDefinedNetworkSpec, +): UserDefinedNetworkSpec => { + const currentConfiguration = + currentNetworkSpec.layer2 || currentNetworkSpec.layer3 || currentNetworkSpec.localnet; + + switch (newTopology) { + case 'Layer2': + const layer2Subnet = currentNetworkSpec.layer3 + ? convertSubnetsFromLayer3(currentNetworkSpec) + : currentConfiguration.subnets; + + return { + layer2: { + ipam: defaultConfiguration.ipam, + role: currentConfiguration.role, + subnets: layer2Subnet as UserDefinedNetworkSpec['layer2']['subnets'], + }, + topology: 'Layer2', + }; + + case 'Layer3': + const layer3Subnets = currentNetworkSpec.layer3 + ? currentConfiguration.subnets + : convertToLayer3Subnets(currentNetworkSpec); + + return { + layer3: { + role: UserDefinedNetworkRole.Primary, + subnets: layer3Subnets as UserDefinedNetworkSpec['layer3']['subnets'], + }, + topology: 'Layer3', + }; + case 'Localnet': + const localnetSubnets = currentNetworkSpec.layer3 + ? convertSubnetsFromLayer3(currentNetworkSpec) + : currentConfiguration.subnets; + return { + localnet: { + physicalNetworkName: '', + role: UserDefinedNetworkRole.Secondary, + subnets: localnetSubnets as UserDefinedNetworkSpec['localnet']['subnets'], + }, + topology: 'Localnet', + }; + } +}; + +export const createNetworkSpecFromRole = ( + currentNetworkSpec: UserDefinedNetworkSpec, + role: UserDefinedNetworkRole, +): UserDefinedNetworkSpec => { + const currentConfiguration = + currentNetworkSpec.layer2 || currentNetworkSpec.layer3 || currentNetworkSpec.localnet; + + const subnets = currentNetworkSpec.layer3 + ? convertSubnetsFromLayer3(currentNetworkSpec) + : currentConfiguration.subnets; + + if (role === UserDefinedNetworkRole.Primary) + return { + layer2: { + ...defaultConfiguration, + subnets: subnets as UserDefinedNetworkSpec['layer2']['subnets'], + }, + + topology: 'Layer2', + }; + + return { + localnet: { + ...defaultConfiguration, + physicalNetworkName: '', + role, + subnets: subnets as UserDefinedNetworkSpec['localnet']['subnets'], + }, + topology: 'localnet', + }; +}; From e0513e23fb424ae950083fc9fd3993222a6a191a Mon Sep 17 00:00:00 2001 From: Ugo Palatucci Date: Thu, 17 Apr 2025 13:39:59 +0200 Subject: [PATCH 2/2] rename CIRD to CIDR --- locales/en/plugin__networking-console-plugin.json | 4 +++- .../SubnetCIDRHelperText.tsx} | 4 ++-- src/views/createprojectmodal/components/NetworkTab.tsx | 2 +- src/views/udns/list/components/SubnetsInput.tsx | 6 +++--- 4 files changed, 9 insertions(+), 7 deletions(-) rename src/utils/components/{SubnetCIRDHelperText/SubnetCIRDHelperText.tsx => SubnetCIDRHelperText/SubnetCIDRHelperText.tsx} (87%) diff --git a/locales/en/plugin__networking-console-plugin.json b/locales/en/plugin__networking-console-plugin.json index 558a0afb..fd7bdf83 100644 --- a/locales/en/plugin__networking-console-plugin.json +++ b/locales/en/plugin__networking-console-plugin.json @@ -273,6 +273,7 @@ "Percent": "Percent", "Persistent": "Persistent", "Phase": "Phase", + "Physical network name": "Physical network name", "Physical network name. A bridge mapping must be configured on cluster nodes to map between physical network names and Open vSwitch bridges.": "Physical network name. A bridge mapping must be configured on cluster nodes to map between physical network names and Open vSwitch bridges.", "Please <2>try again.": "Please <2>try again.", "Pod crash loop back-off": "Pod crash loop back-off", @@ -355,7 +356,7 @@ "Sources added to this rule will allow traffic to the pods defined above. Sources in this list are combined using a logical OR operation.": "Sources added to this rule will allow traffic to the pods defined above. Sources in this list are combined using a logical OR operation.", "Status": "Status", "Subnet": "Subnet", - "Subnet CIRD": "Subnet CIRD", + "Subnet CIDR": "Subnet CIDR", "Subnets": "Subnets", "Subnets are used for the pod network across the cluster.": "Subnets are used for the pod network across the cluster.", "Switch and delete": "Switch and delete", @@ -367,6 +368,7 @@ "Terminating": "Terminating", "Termination type": "Termination type", "The format should match standard CIDR notation (for example, \"10.128.0.0/16\").": "The format should match standard CIDR notation (for example, \"10.128.0.0/16\").", + "The name of the physical network. This attribute must match the value of the spec.desiredState.ovn.bridge-mappings.localnet field of the NodeNetworkConfigurationPolicy object that defines the OVS bridge mapping. ": "The name of the physical network. This attribute must match the value of the spec.desiredState.ovn.bridge-mappings.localnet field of the NodeNetworkConfigurationPolicy object that defines the OVS bridge mapping. ", "The only allowed value is Persistent. When set, OVN Kubernetes assigned IP addresses will be persisted in an \"ipamclaims.k8s.cni.cncf.io\" object.": "The only allowed value is Persistent. When set, OVN Kubernetes assigned IP addresses will be persisted in an \"ipamclaims.k8s.cni.cncf.io\" object.", "These IP addresses will be reused by other pods if requested.": "These IP addresses will be reused by other pods if requested.", "These rules are handled by a routing layer (Ingress Controller) which is updated as the rules are modified. The Ingress controller implementation defines how headers and other metadata are forwarded or manipulated": "These rules are handled by a routing layer (Ingress Controller) which is updated as the rules are modified. The Ingress controller implementation defines how headers and other metadata are forwarded or manipulated", diff --git a/src/utils/components/SubnetCIRDHelperText/SubnetCIRDHelperText.tsx b/src/utils/components/SubnetCIDRHelperText/SubnetCIDRHelperText.tsx similarity index 87% rename from src/utils/components/SubnetCIRDHelperText/SubnetCIRDHelperText.tsx rename to src/utils/components/SubnetCIDRHelperText/SubnetCIDRHelperText.tsx index ee970b30..a62c3bfd 100644 --- a/src/utils/components/SubnetCIRDHelperText/SubnetCIRDHelperText.tsx +++ b/src/utils/components/SubnetCIDRHelperText/SubnetCIDRHelperText.tsx @@ -4,7 +4,7 @@ import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation' import FormGroupHelperText from '../FormGroupHelperText/FormGroupHelperText'; -const SubnetCIRDHelperText: FC = () => { +const SubnetCIDRHelperText: FC = () => { const { t } = useNetworkingTranslation(); return ( @@ -16,4 +16,4 @@ const SubnetCIRDHelperText: FC = () => { ); }; -export default SubnetCIRDHelperText; +export default SubnetCIDRHelperText; diff --git a/src/views/createprojectmodal/components/NetworkTab.tsx b/src/views/createprojectmodal/components/NetworkTab.tsx index 8a5a0cc3..19c75467 100644 --- a/src/views/createprojectmodal/components/NetworkTab.tsx +++ b/src/views/createprojectmodal/components/NetworkTab.tsx @@ -70,7 +70,7 @@ const NetworkTab: FC = () => { {networkType === NETWORK_TYPE.UDN && ( <> - + = ({ isClusterUDN }) => { : subnets?.join(','); return ( - + = ({ isClusterUDN }) => { type="text" value={subnetsText} /> - + ); };