diff --git a/libs/ui-lib/lib/common/components/clusterConfiguration/utils.test.ts b/libs/ui-lib/lib/common/components/clusterConfiguration/utils.test.ts new file mode 100644 index 0000000000..516649dc68 --- /dev/null +++ b/libs/ui-lib/lib/common/components/clusterConfiguration/utils.test.ts @@ -0,0 +1,275 @@ +import { test, describe, expect } from 'vitest'; +import { isDualStack } from './utils'; +import { + MachineNetwork, + ClusterNetwork, + ServiceNetwork, +} from '@openshift-assisted/types/assisted-installer-service'; + +describe('isDualStack', () => { + const createMachineNetwork = (cidr: string): MachineNetwork => ({ cidr, clusterId: 'test' }); + const createClusterNetwork = (cidr: string, hostPrefix: number): ClusterNetwork => ({ + cidr, + hostPrefix, + clusterId: 'test', + }); + const createServiceNetwork = (cidr: string): ServiceNetwork => ({ cidr, clusterId: 'test' }); + + describe('OCP < 4.12 (legacy behavior)', () => { + test('returns true for valid dual stack with IPv4 primary', () => { + const result = isDualStack({ + machineNetworks: [ + createMachineNetwork('192.168.1.0/24'), + createMachineNetwork('2001:db8::/64'), + ], + clusterNetworks: [ + createClusterNetwork('10.128.0.0/14', 23), + createClusterNetwork('fd01::/48', 64), + ], + serviceNetworks: [ + createServiceNetwork('172.30.0.0/16'), + createServiceNetwork('fd02::/112'), + ], + openshiftVersion: '4.11', + }); + + expect(result).toBe(true); + }); + + test('returns false for IPv6 primary in OCP < 4.12', () => { + const result = isDualStack({ + machineNetworks: [ + createMachineNetwork('2001:db8::/64'), + createMachineNetwork('192.168.1.0/24'), + ], + clusterNetworks: [ + createClusterNetwork('fd01::/48', 64), + createClusterNetwork('10.128.0.0/14', 23), + ], + serviceNetworks: [ + createServiceNetwork('fd02::/112'), + createServiceNetwork('172.30.0.0/16'), + ], + openshiftVersion: '4.11', + }); + + expect(result).toBe(false); + }); + + test('returns false for single stack', () => { + const result = isDualStack({ + machineNetworks: [createMachineNetwork('192.168.1.0/24')], + clusterNetworks: [createClusterNetwork('10.128.0.0/14', 23)], + serviceNetworks: [createServiceNetwork('172.30.0.0/16')], + openshiftVersion: '4.11', + }); + + expect(result).toBe(false); + }); + + test('returns false for empty networks', () => { + const result = isDualStack({ + machineNetworks: [], + clusterNetworks: [], + serviceNetworks: [], + openshiftVersion: '4.11', + }); + + expect(result).toBe(false); + }); + }); + + describe('OCP >= 4.12 (new behavior)', () => { + test('returns true for IPv4 primary and IPv6 secondary', () => { + const result = isDualStack({ + machineNetworks: [ + createMachineNetwork('192.168.1.0/24'), + createMachineNetwork('2001:db8::/64'), + ], + clusterNetworks: [ + createClusterNetwork('10.128.0.0/14', 23), + createClusterNetwork('fd01::/48', 64), + ], + serviceNetworks: [ + createServiceNetwork('172.30.0.0/16'), + createServiceNetwork('fd02::/112'), + ], + openshiftVersion: '4.12', + }); + + expect(result).toBe(true); + }); + + test('returns true for IPv6 primary and IPv4 secondary', () => { + const result = isDualStack({ + machineNetworks: [ + createMachineNetwork('2001:db8::/64'), + createMachineNetwork('192.168.1.0/24'), + ], + clusterNetworks: [ + createClusterNetwork('fd01::/48', 64), + createClusterNetwork('10.128.0.0/14', 23), + ], + serviceNetworks: [ + createServiceNetwork('fd02::/112'), + createServiceNetwork('172.30.0.0/16'), + ], + openshiftVersion: '4.12', + }); + + expect(result).toBe(true); + }); + + test('returns false for IPv6 primary and IPv6 secondary (not dual-stack)', () => { + const result = isDualStack({ + machineNetworks: [ + createMachineNetwork('2001:db8::/64'), + createMachineNetwork('2001:db9::/64'), + ], + clusterNetworks: [ + createClusterNetwork('fd01::/48', 64), + createClusterNetwork('fd02::/48', 64), + ], + serviceNetworks: [createServiceNetwork('fd03::/112'), createServiceNetwork('fd04::/112')], + openshiftVersion: '4.12', + }); + + expect(result).toBe(false); + }); + + test('returns false for IPv4 primary and IPv4 secondary (not dual-stack)', () => { + const result = isDualStack({ + machineNetworks: [ + createMachineNetwork('192.168.1.0/24'), + createMachineNetwork('10.0.0.0/24'), + ], + clusterNetworks: [ + createClusterNetwork('10.128.0.0/14', 23), + createClusterNetwork('172.16.0.0/14', 23), + ], + serviceNetworks: [ + createServiceNetwork('172.30.0.0/16'), + createServiceNetwork('172.31.0.0/16'), + ], + openshiftVersion: '4.12', + }); + + expect(result).toBe(false); + }); + + test('returns false for single stack', () => { + const result = isDualStack({ + machineNetworks: [createMachineNetwork('192.168.1.0/24')], + clusterNetworks: [createClusterNetwork('10.128.0.0/14', 23)], + serviceNetworks: [createServiceNetwork('172.30.0.0/16')], + openshiftVersion: '4.12', + }); + + expect(result).toBe(false); + }); + + test('returns false if any network type is not dual stack', () => { + const result = isDualStack({ + machineNetworks: [ + createMachineNetwork('192.168.1.0/24'), + createMachineNetwork('2001:db8::/64'), + ], + clusterNetworks: [createClusterNetwork('10.128.0.0/14', 23)], // Single stack + serviceNetworks: [ + createServiceNetwork('172.30.0.0/16'), + createServiceNetwork('fd02::/112'), + ], + openshiftVersion: '4.12', + }); + + expect(result).toBe(false); + }); + }); + + describe('no version specified (defaults to legacy behavior)', () => { + test('returns true for IPv4 primary and IPv6 secondary', () => { + const result = isDualStack({ + machineNetworks: [ + createMachineNetwork('192.168.1.0/24'), + createMachineNetwork('2001:db8::/64'), + ], + clusterNetworks: [ + createClusterNetwork('10.128.0.0/14', 23), + createClusterNetwork('fd01::/48', 64), + ], + serviceNetworks: [ + createServiceNetwork('172.30.0.0/16'), + createServiceNetwork('fd02::/112'), + ], + }); + + expect(result).toBe(true); + }); + + test('returns false for IPv6 primary when no version specified', () => { + const result = isDualStack({ + machineNetworks: [ + createMachineNetwork('2001:db8::/64'), + createMachineNetwork('192.168.1.0/24'), + ], + clusterNetworks: [ + createClusterNetwork('fd01::/48', 64), + createClusterNetwork('10.128.0.0/14', 23), + ], + serviceNetworks: [ + createServiceNetwork('fd02::/112'), + createServiceNetwork('172.30.0.0/16'), + ], + }); + + expect(result).toBe(false); + }); + }); + + describe('edge cases', () => { + test('handles undefined networks', () => { + const result = isDualStack({ + machineNetworks: undefined, + clusterNetworks: undefined, + serviceNetworks: undefined, + openshiftVersion: '4.12', + }); + + expect(result).toBe(false); + }); + + test('handles networks with empty CIDRs', () => { + const result = isDualStack({ + machineNetworks: [ + { cidr: '', clusterId: 'test' }, + { cidr: '', clusterId: 'test' }, + ], + clusterNetworks: [ + { cidr: '', hostPrefix: 23, clusterId: 'test' }, + { cidr: '', hostPrefix: 64, clusterId: 'test' }, + ], + serviceNetworks: [ + { cidr: '', clusterId: 'test' }, + { cidr: '', clusterId: 'test' }, + ], + openshiftVersion: '4.12', + }); + + expect(result).toBe(false); + }); + + test('handles invalid CIDRs', () => { + const result = isDualStack({ + machineNetworks: [createMachineNetwork('invalid'), createMachineNetwork('also-invalid')], + clusterNetworks: [ + createClusterNetwork('invalid', 23), + createClusterNetwork('also-invalid', 64), + ], + serviceNetworks: [createServiceNetwork('invalid'), createServiceNetwork('also-invalid')], + openshiftVersion: '4.12', + }); + + expect(result).toBe(false); + }); + }); +}); diff --git a/libs/ui-lib/lib/common/components/clusterConfiguration/utils.ts b/libs/ui-lib/lib/common/components/clusterConfiguration/utils.ts index fce96f253d..6cb5759bdd 100644 --- a/libs/ui-lib/lib/common/components/clusterConfiguration/utils.ts +++ b/libs/ui-lib/lib/common/components/clusterConfiguration/utils.ts @@ -10,6 +10,7 @@ import { ServiceNetwork, } from '@openshift-assisted/types/assisted-installer-service'; import { NO_SUBNET_SET } from '../../config'; +import { isMajorMinorVersionEqualOrGreater } from '../../utils'; import { selectClusterNetworkCIDR, selectClusterNetworkHostPrefix, @@ -179,20 +180,38 @@ export const canBeDualStack = (subnets: HostSubnets) => const areNetworksDualStack = ( networks: (MachineNetwork | ClusterNetwork | ServiceNetwork)[] | undefined, -) => - networks && - networks.length > 1 && - Address4.isValid(networks[0].cidr || '') && - Address6.isValid(networks[1].cidr || ''); + openshiftVersion?: string, +) => { + if (!networks || networks.length < 2) { + return false; + } + + const firstCidr = networks[0].cidr || ''; + const secondCidr = networks[1].cidr || ''; + + // For OCP >= 4.12, allow either IPv4 or IPv6 as primary, with opposite as secondary + if (openshiftVersion && isMajorMinorVersionEqualOrGreater(openshiftVersion, '4.12')) { + return ( + (Address4.isValid(firstCidr) && Address6.isValid(secondCidr)) || + (Address6.isValid(firstCidr) && Address4.isValid(secondCidr)) + ); + } + + // For older versions, require IPv4 as primary and IPv6 as secondary + return Address4.isValid(firstCidr) && Address6.isValid(secondCidr); +}; export const isDualStack = ({ machineNetworks, clusterNetworks, serviceNetworks, -}: Pick) => - areNetworksDualStack(machineNetworks) && - areNetworksDualStack(clusterNetworks) && - areNetworksDualStack(serviceNetworks); + openshiftVersion, +}: Pick & { + openshiftVersion?: string; +}) => + areNetworksDualStack(machineNetworks, openshiftVersion) && + areNetworksDualStack(clusterNetworks, openshiftVersion) && + areNetworksDualStack(serviceNetworks, openshiftVersion); export const isSubnetInIPv6 = ({ clusterNetworkCidr, diff --git a/libs/ui-lib/lib/common/components/ui/formik/_validationSchemas.test.ts b/libs/ui-lib/lib/common/components/ui/formik/_validationSchemas.test.ts index dbfc51da45..fd54185288 100644 --- a/libs/ui-lib/lib/common/components/ui/formik/_validationSchemas.test.ts +++ b/libs/ui-lib/lib/common/components/ui/formik/_validationSchemas.test.ts @@ -2,6 +2,7 @@ import { test, describe, expect } from 'vitest'; import { baseDomainValidationSchema, dnsNameValidationSchema, + dualStackValidationSchema, hostPrefixValidationSchema, ipBlockValidationSchema, ipValidationSchema, @@ -347,4 +348,85 @@ describe('validationSchemas', () => { expect(counter).toBe(invalid.length); }); + + describe('dualStackValidationSchema', () => { + describe('OCP < 4.12 (legacy behavior)', () => { + const schema = dualStackValidationSchema('machine networks', '4.11'); + + test('validates IPv4 as primary and IPv6 as secondary', async () => { + const validValues = [[{ cidr: '192.168.1.0/24' }, { cidr: '2001:db8::/64' }]]; + + for (const value of validValues) { + await expect(schema.validate(value)).resolves.toBe(value); + } + }); + + test('rejects IPv6 as primary for OCP < 4.12', async () => { + const invalidValues = [[{ cidr: '2001:db8::/64' }, { cidr: '192.168.1.0/24' }]]; + + for (const value of invalidValues) { + await expect(schema.validate(value)).rejects.toThrow( + 'First network has to be IPv4 subnet', + ); + } + }); + }); + + describe('OCP >= 4.12 (new behavior)', () => { + const schema = dualStackValidationSchema('machine networks', '4.12'); + + test('validates IPv4 as primary and IPv6 as secondary', async () => { + const validValues = [[{ cidr: '192.168.1.0/24' }, { cidr: '2001:db8::/64' }]]; + + for (const value of validValues) { + await expect(schema.validate(value)).resolves.toBe(value); + } + }); + + test('validates IPv6 as primary and IPv4 as secondary', async () => { + const validValues = [[{ cidr: '2001:db8::/64' }, { cidr: '192.168.1.0/24' }]]; + + for (const value of validValues) { + await expect(schema.validate(value)).resolves.toBe(value); + } + }); + }); + + describe('no version specified (defaults to legacy behavior)', () => { + const schema = dualStackValidationSchema('machine networks'); + + test('validates IPv4 as primary and IPv6 as secondary', async () => { + const validValues = [[{ cidr: '192.168.1.0/24' }, { cidr: '2001:db8::/64' }]]; + + for (const value of validValues) { + await expect(schema.validate(value)).resolves.toBe(value); + } + }); + + test('rejects IPv6 as primary when no version specified', async () => { + const invalidValues = [[{ cidr: '2001:db8::/64' }, { cidr: '192.168.1.0/24' }]]; + + for (const value of invalidValues) { + await expect(schema.validate(value)).rejects.toThrow( + 'First network has to be IPv4 subnet', + ); + } + }); + }); + + describe('edge cases', () => { + test('validates maximum 2 networks', async () => { + const schema = dualStackValidationSchema('machine networks', '4.12'); + const invalidValue = [ + { cidr: '192.168.1.0/24' }, + { cidr: '2001:db8::/64' }, + { cidr: '10.0.0.0/24' }, + ]; + + await expect(schema.validate(invalidValue)).rejects.toThrow( + 'Maximum number of machine networks subnets in dual stack is 2', + ); + }); + }); + }); }); diff --git a/libs/ui-lib/lib/common/components/ui/formik/validationSchemas.ts b/libs/ui-lib/lib/common/components/ui/formik/validationSchemas.ts index 54614c5b8a..a3577ab314 100644 --- a/libs/ui-lib/lib/common/components/ui/formik/validationSchemas.ts +++ b/libs/ui-lib/lib/common/components/ui/formik/validationSchemas.ts @@ -28,6 +28,7 @@ import { nameValidationMessages, } from './constants'; import { allSubnetsIPv4, getAddress, trimCommaSeparatedList, trimSshPublicKey } from './utils'; +import { isMajorMinorVersionEqualOrGreater } from '../../../utils'; import { selectApiVip, selectIngressVip } from '../../../selectors'; import { ClusterDetailsValues } from '../../clusterWizard/types'; @@ -703,20 +704,25 @@ export const serviceNetworkValidationSchema = Yup.array().of( }), ); -export const dualStackValidationSchema = (field: string) => +export const dualStackValidationSchema = (field: string, openshiftVersion?: string) => Yup.array() .max(2, `Maximum number of ${field} subnets in dual stack is 2.`) .test( 'dual-stack-ipv4', - 'First network has to be IPv4 subnet.', - (values?: { cidr: MachineNetwork['cidr'] }[]): boolean => - !!values?.[0].cidr && Address4.isValid(values[0].cidr), - ) - .test( - 'dual-stack-ipv6', - 'Second network has to be IPv6 subnet.', - (values?: { cidr: MachineNetwork['cidr'] }[]): boolean => - !!values?.[1].cidr && Address6.isValid(values[1].cidr), + openshiftVersion && isMajorMinorVersionEqualOrGreater(openshiftVersion, '4.12') + ? 'First network has to be a valid IPv4 or IPv6 subnet.' + : 'First network has to be IPv4 subnet.', + (values?: { cidr: MachineNetwork['cidr'] }[]): boolean => { + // For OCP versions > 4.11, allow IPv6 as primary network + if (openshiftVersion && isMajorMinorVersionEqualOrGreater(openshiftVersion, '4.12')) { + return ( + !!values?.[0].cidr && + (Address4.isValid(values[0].cidr) || Address6.isValid(values[0].cidr)) + ); + } + // For older versions, require IPv4 as primary network + return !!values?.[0].cidr && Address4.isValid(values[0].cidr); + }, ); export const IPv4ValidationSchema = Yup.array().test( diff --git a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/AdvancedNetworkFields.tsx b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/AdvancedNetworkFields.tsx index dd3c8866ec..252082a061 100644 --- a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/AdvancedNetworkFields.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/AdvancedNetworkFields.tsx @@ -8,6 +8,7 @@ import { Grid, } from '@patternfly/react-core'; import { FieldArray, useFormikContext } from 'formik'; +import { Address6 } from 'ip-address'; import { DUAL_STACK, PREFIX_MAX_RESTRICTION, @@ -16,10 +17,6 @@ import { } from '../../../../common'; import { OcmInputField } from '../../ui/OcmFormFields'; -const getNetworkLabelSuffix = (index: number, isDualStack: boolean) => { - return isDualStack ? ` (${index === 0 ? 'IPv4' : 'IPv6'})` : ''; -}; - const IPv4PrefixPopoverText = 'For example, if Cluster Network Host Prefix is set to 23, then each node is assigned a /23 subnet out of the given cidr (clusterNetworkCIDR), which allows for 510 (2^(32 - 23) - 2) pod IPs addresses.'; @@ -36,10 +33,62 @@ const serviceCidrHelperText = 'Enter only 1 IP address pool. If you need to access the services from an external network, configure load balancers and routers to manage the traffic.'; const AdvancedNetworkFields = () => { - const { values, errors } = useFormikContext(); + const { values, errors, setFieldValue } = useFormikContext(); const isDualStack = values.stackType === DUAL_STACK; + // Reorder Cluster Networks and Service Networks when Machine Network primary changes + React.useEffect(() => { + if (!isDualStack || !values.machineNetworks?.[0]?.cidr) return; + + const primaryMachineNetworkCidr = values.machineNetworks[0].cidr; + const isPrimaryIPv6 = Address6.isValid(primaryMachineNetworkCidr); + + // Check Cluster Networks + if (values.clusterNetworks && values.clusterNetworks.length >= 2) { + const firstClusterCidr = values.clusterNetworks[0].cidr; + if (!firstClusterCidr) return; + + const isFirstClusterIPv6 = Address6.isValid(firstClusterCidr); + + // If primary machine is IPv6 but first cluster is IPv4, swap them + if (isPrimaryIPv6 && !isFirstClusterIPv6) { + const reordered = [values.clusterNetworks[1], values.clusterNetworks[0]]; + setFieldValue('clusterNetworks', reordered, false); + } + // If primary machine is IPv4 but first cluster is IPv6, swap them + else if (!isPrimaryIPv6 && isFirstClusterIPv6) { + const reordered = [values.clusterNetworks[1], values.clusterNetworks[0]]; + setFieldValue('clusterNetworks', reordered, false); + } + } + + // Check Service Networks + if (values.serviceNetworks && values.serviceNetworks.length >= 2) { + const firstServiceCidr = values.serviceNetworks[0].cidr; + if (!firstServiceCidr) return; + + const isFirstServiceIPv6 = Address6.isValid(firstServiceCidr); + + // If primary machine is IPv6 but first service is IPv4, swap them + if (isPrimaryIPv6 && !isFirstServiceIPv6) { + const reordered = [values.serviceNetworks[1], values.serviceNetworks[0]]; + setFieldValue('serviceNetworks', reordered, false); + } + // If primary machine is IPv4 but first service is IPv6, swap them + else if (!isPrimaryIPv6 && isFirstServiceIPv6) { + const reordered = [values.serviceNetworks[1], values.serviceNetworks[0]]; + setFieldValue('serviceNetworks', reordered, false); + } + } + }, [ + isDualStack, + values.machineNetworks, + values.clusterNetworks, + values.serviceNetworks, + setFieldValue, + ]); + const clusterNetworkCidrPrefix = (index: number) => parseInt( ((values.clusterNetworks && values.clusterNetworks[index].cidr) || '').split('/')[1], @@ -66,7 +115,6 @@ const AdvancedNetworkFields = () => { {() => ( {values.clusterNetworks?.map((_, index) => { - const networkSuffix = getNetworkLabelSuffix(index, isDualStack); return ( @@ -74,7 +122,7 @@ const AdvancedNetworkFields = () => { name={`clusterNetworks.${index}.cidr`} label={ <> - {`Cluster network CIDR${networkSuffix} `} + Cluster network CIDR @@ -82,7 +130,7 @@ const AdvancedNetworkFields = () => { } helperText={clusterCidrHelperText} isRequired - labelInfo={index === 0 && isDualStack ? 'Primary' : ''} + labelInfo={isDualStack ? (index === 0 ? 'Primary' : 'Secondary') : ''} /> @@ -90,7 +138,7 @@ const AdvancedNetworkFields = () => { name={`clusterNetworks.${index}.hostPrefix`} label={ <> - Cluster network host prefix{networkSuffix} + Cluster network host prefix { name={`serviceNetworks.${index}.cidr`} label={ <> - - {`Service network CIDR${getNetworkLabelSuffix(index, isDualStack)} `} - + Service network CIDR @@ -138,7 +184,7 @@ const AdvancedNetworkFields = () => { } helperText={serviceCidrHelperText} isRequired - labelInfo={index === 0 && isDualStack ? 'Primary' : ''} + labelInfo={isDualStack ? (index === 0 ? 'Primary' : 'Secondary') : ''} /> ))} diff --git a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/AvailableSubnetsControl.tsx b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/AvailableSubnetsControl.tsx index 02ba2e0548..dfc5185e8f 100644 --- a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/AvailableSubnetsControl.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/AvailableSubnetsControl.tsx @@ -9,6 +9,7 @@ import { NetworkConfigurationValues, DUAL_STACK, NO_SUBNET_SET, + isMajorMinorVersionEqualOrGreater, } from '../../../../common'; import { selectCurrentClusterPermissionsState } from '../../../store/slices/current-cluster/selectors'; import { SubnetsDropdown } from './SubnetsDropdown'; @@ -35,6 +36,7 @@ export interface AvailableSubnetsControlProps { hostSubnets: HostSubnet[]; isRequired: boolean; isDisabled: boolean; + openshiftVersion?: string; } export const AvailableSubnetsControl = ({ @@ -42,11 +44,16 @@ export const AvailableSubnetsControl = ({ hostSubnets, isRequired = false, isDisabled, + openshiftVersion, }: AvailableSubnetsControlProps) => { const { values, errors, setFieldValue } = useFormikContext(); const isDualStack = values.stackType === DUAL_STACK; const { isViewerMode } = useSelector(selectCurrentClusterPermissionsState); + // Check if OCP version supports IPv6 as primary network + const supportsIPv6Primary = + openshiftVersion && isMajorMinorVersionEqualOrGreater(openshiftVersion, '4.12'); + const IPv4Subnets = hostSubnets .filter((subnet) => Address4.isValid(subnet.subnet)) .sort(subnetSort); @@ -54,53 +61,106 @@ export const AvailableSubnetsControl = ({ .filter((subnet) => Address6.isValid(subnet.subnet)) .sort(subnetSort); + // For OCP >= 4.12, both networks can use either IPv4 or IPv6 + const allSubnets = supportsIPv6Primary + ? [...IPv4Subnets, ...IPv6Subnets].sort(subnetSort) + : IPv4Subnets; + + // For auto-selection, always use IPv4 subnets for single-stack const cidr = IPv4Subnets.length >= 1 ? IPv4Subnets[0].subnet : NO_SUBNET_SET; const hasEmptySelection = (values.machineNetworks ?? []).length === 0; const autoSelectNetwork = !isViewerMode && hasEmptySelection; useAutoSelectSingleAvailableSubnet(autoSelectNetwork, setFieldValue, cidr, clusterId); return ( - - - {() => ( - - {isDualStack ? ( - values.machineNetworks?.map((_machineNetwork, index) => { - const machineSubnets = index === 1 ? IPv6Subnets : IPv4Subnets; - - return ( - - - - ); - }) - ) : ( - + <> + + + {() => ( + + {isDualStack ? ( + values.machineNetworks?.map((machineNetwork, index) => { + if (index > 0) return null; + + const machineSubnets = supportsIPv6Primary ? allSubnets : IPv4Subnets; + + return ( + + + + ); + }) + ) : ( + + + + )} + + )} + + + {typeof errors.machineNetworks === 'string' && ( + + )} + + + {isDualStack && values.machineNetworks && values.machineNetworks.length > 1 && ( + + + {() => { + const index = 1; + let machineSubnets; + + if (supportsIPv6Primary) { + // For OCP >= 4.12, smart filtering based on the other network's selection + const firstNetworkCidr = values.machineNetworks?.[0]?.cidr; + if (firstNetworkCidr && Address6.isValid(firstNetworkCidr)) { + // If first is IPv6, second should be IPv4 + machineSubnets = IPv4Subnets; + } else if (firstNetworkCidr && Address4.isValid(firstNetworkCidr)) { + // If first is IPv4, second should be IPv6 + machineSubnets = IPv6Subnets; + } else { + // If first is not selected yet, show all subnets + machineSubnets = allSubnets; + } + } else { + // For older versions, second network is IPv6 + machineSubnets = IPv6Subnets; + } + + return ( - - )} - - )} - - - {typeof errors.machineNetworks === 'string' && ( - + ); + }} + + )} - + ); }; diff --git a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/NetworkConfiguration.tsx b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/NetworkConfiguration.tsx index d8dc2be076..6b6c45c9ca 100644 --- a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/NetworkConfiguration.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/NetworkConfiguration.tsx @@ -224,10 +224,10 @@ const NetworkConfiguration = ({ ); React.useEffect(() => { - if (!isViewerMode && isDualStack && !isUserManagedNetworking) { + if (!isViewerMode && isDualStack) { toggleAdvConfiguration(true); } - }, [isDualStack, isUserManagedNetworking, isViewerMode, toggleAdvConfiguration]); + }, [isDualStack, isViewerMode, toggleAdvConfiguration]); const managedNetworkingState = React.useMemo( () => @@ -292,6 +292,7 @@ const NetworkConfiguration = ({ hostSubnets.length === 0 || false } + openshiftVersion={cluster.openshiftVersion} /> )} diff --git a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/NetworkConfigurationForm.tsx b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/NetworkConfigurationForm.tsx index c980f7d640..c2ae715b22 100644 --- a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/NetworkConfigurationForm.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/NetworkConfigurationForm.tsx @@ -200,8 +200,9 @@ const NetworkConfigurationPage = ({ cluster }: { cluster: Cluster }) => { ); const memoizedValidationSchema = React.useMemo( - () => getNetworkConfigurationValidationSchema(initialValues, hostSubnets), - [hostSubnets, initialValues], + () => + getNetworkConfigurationValidationSchema(initialValues, hostSubnets, cluster.openshiftVersion), + [hostSubnets, initialValues, cluster.openshiftVersion], ); React.useEffect(() => { diff --git a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/StackTypeControl.tsx b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/StackTypeControl.tsx index f750aaba6a..f5fa51dade 100644 --- a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/StackTypeControl.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/StackTypeControl.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useSelector } from 'react-redux'; import { ButtonVariant, FormGroup, Split, SplitItem, Tooltip } from '@patternfly/react-core'; import { useFormikContext } from 'formik'; -import { Address6 } from 'ip-address'; +import { Address4, Address6 } from 'ip-address'; import { HostSubnets, @@ -69,19 +69,67 @@ export const StackTypeControlGroup = ({ setFieldValue('stackType', IPV4_STACK); if (values.machineNetworks && values.machineNetworks?.length >= 2) { - setFieldValue('machineNetworks', values.machineNetworks.slice(0, 1)); + // For single-stack IPv4, prefer IPv4 machine network + const firstNetwork = values.machineNetworks[0]; + const isFirstIPv4 = firstNetwork?.cidr && Address4.isValid(firstNetwork.cidr); + + if (isFirstIPv4) { + // Keep the first network if it's IPv4 + setFieldValue('machineNetworks', [firstNetwork]); + } else { + // If first is IPv6, look for IPv4 network or set empty for auto-selection + const ipv4Network = values.machineNetworks.find( + (network) => network.cidr && Address4.isValid(network.cidr), + ); + if (ipv4Network) { + setFieldValue('machineNetworks', [ipv4Network]); + } else { + // No IPv4 network found, set empty for auto-selection + setFieldValue('machineNetworks', []); + } + } } - if (values.clusterNetworks && values.clusterNetworks?.length >= 2) { - setFieldValue('clusterNetworks', values.clusterNetworks.slice(0, 1)); + // Ensure single-stack inputs are IPv4 after switching from dual-stack + if (values.clusterNetworks && values.clusterNetworks.length >= 1) { + const defaultCluster = + defaultNetworkValues.clusterNetworksIpv4 && defaultNetworkValues.clusterNetworksIpv4[0]; + const firstCluster = values.clusterNetworks[0]; + const ipv4Cluster = defaultCluster + ? { + cidr: defaultCluster.cidr, + hostPrefix: defaultCluster.hostPrefix, + clusterId: firstCluster.clusterId, // ← Preserve clusterId from original network + } + : { + cidr: '', + hostPrefix: '', + clusterId: clusterId, // ← Use current clusterId + }; + setFieldValue('clusterNetworks', [ipv4Cluster]); } - if (values.serviceNetworks && values.serviceNetworks?.length >= 2) { - setFieldValue('serviceNetworks', values.serviceNetworks.slice(0, 1)); + if (values.serviceNetworks && values.serviceNetworks.length >= 1) { + const defaultService = + defaultNetworkValues.serviceNetworksIpv4 && defaultNetworkValues.serviceNetworksIpv4[0]; + const firstService = values.serviceNetworks[0]; + const ipv4Service = defaultService + ? { + cidr: defaultService.cidr, + clusterId: firstService.clusterId, // ← Preserve clusterId from original network + } + : { + cidr: '', + clusterId: clusterId, // ← Use current clusterId + }; + setFieldValue('serviceNetworks', [ipv4Service]); } void validateForm(); }, [ setFieldValue, validateForm, + clusterId, + defaultNetworkValues.clusterNetworksIpv4, + defaultNetworkValues.serviceNetworksIpv4, values.clusterNetworks, values.machineNetworks, values.serviceNetworks, @@ -96,11 +144,30 @@ export const StackTypeControlGroup = ({ } if (values.machineNetworks && values.machineNetworks?.length < 2) { - setFieldValue( - 'machineNetworks', - [...values.machineNetworks, { cidr: cidrIPv6, clusterId: clusterId }], - false, - ); + // Ensure IPv4 is primary when switching to dual-stack + const firstNetwork = values.machineNetworks[0]; + const isFirstIPv6 = firstNetwork?.cidr && Address6.isValid(firstNetwork.cidr); + + if (isFirstIPv6) { + // If first network is IPv6, make IPv4 primary and IPv6 secondary + const IPv4Subnets = hostSubnets.filter((subnet) => Address4.isValid(subnet.subnet)); + const cidrIPv4 = IPv4Subnets.length >= 1 ? IPv4Subnets[0].subnet : NO_SUBNET_SET; + setFieldValue( + 'machineNetworks', + [ + { cidr: cidrIPv4, clusterId: clusterId }, + { cidr: firstNetwork.cidr, clusterId: clusterId }, + ], + false, + ); + } else { + // If first network is IPv4, add IPv6 as secondary + setFieldValue( + 'machineNetworks', + [...values.machineNetworks, { cidr: cidrIPv6, clusterId: clusterId }], + false, + ); + } } if (values.clusterNetworks && values.clusterNetworks?.length < 2) { setFieldValue( diff --git a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/SubnetsDropdown.tsx b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/SubnetsDropdown.tsx index 668121e125..6ef2392dbc 100644 --- a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/SubnetsDropdown.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/SubnetsDropdown.tsx @@ -2,12 +2,14 @@ import React from 'react'; import { Dropdown, DropdownItem, + DropdownGroup, MenuToggle, MenuToggleElement, MenuToggleProps, } from '@patternfly/react-core'; import { useField } from 'formik'; import { getFieldId, HostSubnet, NO_SUBNET_SET } from '../../../../common'; +import { Address4, Address6 } from 'ip-address'; type SubnetsDropdownProps = { name: string; @@ -48,7 +50,7 @@ export const SubnetsDropdown = ({ const [isOpen, setOpen] = React.useState(false); const fieldId = getFieldId(name, 'input'); - const { itemsSubnets, currentDisplayValue } = React.useMemo(() => { + const { itemsSubnets, ipv4Subnets, ipv6Subnets, currentDisplayValue } = React.useMemo(() => { const itemsSubnets = machineSubnets.length === 0 ? [noSubnetAvailableOption] @@ -56,6 +58,10 @@ export const SubnetsDropdown = ({ toFormSelectOptions(machineSubnets), ); + // Separate IPv4 and IPv6 subnets + const ipv4Subnets = machineSubnets.filter((subnet) => Address4.isValid(subnet.subnet)); + const ipv6Subnets = machineSubnets.filter((subnet) => Address6.isValid(subnet.subnet)); + let currentDisplayValue = itemsSubnets[0].label; // The placeholder is the fallback if (field.value) { const subnetItem = itemsSubnets.find((item) => item.value === field.value); @@ -67,14 +73,65 @@ export const SubnetsDropdown = ({ currentDisplayValue = itemsSubnets[1].label; } - return { itemsSubnets, currentDisplayValue: currentDisplayValue }; + return { itemsSubnets, ipv4Subnets, ipv6Subnets, currentDisplayValue: currentDisplayValue }; }, [machineSubnets, field.value]); - const dropdownItems = itemsSubnets.map(({ value, label, isDisabled }) => ( - - {label} - - )); + const dropdownItems = React.useMemo(() => { + if (machineSubnets.length === 0) { + return [ + + {noSubnetAvailableOption.label} + , + ]; + } + + const hasIpv4 = ipv4Subnets.length > 0; + const hasIpv6 = ipv6Subnets.length > 0; + + // If we have both IPv4 and IPv6, group them + if (hasIpv4 && hasIpv6) { + const ipv4Items = toFormSelectOptions(ipv4Subnets).map(({ value, label, isDisabled }) => ( + + {label} + + )); + + const ipv6Items = toFormSelectOptions(ipv6Subnets).map(({ value, label, isDisabled }) => ( + + {label} + + )); + + return [ + + {makeNoSubnetSelectedOption(machineSubnets.length).label} + , + + {ipv4Items} + , + + {ipv6Items} + , + ]; + } + + // If only one type, don't group + return itemsSubnets.map(({ value, label, isDisabled }) => ( + + {label} + + )); + }, [machineSubnets, ipv4Subnets, ipv6Subnets, itemsSubnets]); const onSelect = (event?: React.MouseEvent): void => { setValue(event?.currentTarget.id); @@ -100,7 +157,7 @@ export const SubnetsDropdown = ({ setOpen(!isOpen)} + onOpenChange={(isOpen: boolean) => setOpen(isOpen)} onSelect={onSelect} toggle={toggle} isOpen={isOpen} diff --git a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/VirtualIPControlGroup.tsx b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/VirtualIPControlGroup.tsx index 11403beb7f..97da11f850 100644 --- a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/VirtualIPControlGroup.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/VirtualIPControlGroup.tsx @@ -16,7 +16,6 @@ import { NETWORK_TYPE_SDN, selectMachineNetworkCIDR, getVipValidationsById, - DUAL_STACK, PopoverIcon, selectApiVip, selectIngressVip, @@ -117,7 +116,6 @@ export const VirtualIPControlGroup = ({ setFieldValue(field, [{ ip: e.target.value, clusterId: cluster.id }], true); }; - const ipFieldsSuffix = values.stackType === DUAL_STACK ? ' (IPv4)' : ''; return ( <> {!isVipDhcpAllocationDisabled && ( @@ -145,7 +143,7 @@ export const VirtualIPControlGroup = ({ - API IP{ipFieldsSuffix} + API IP } name="apiVip" @@ -164,8 +162,7 @@ export const VirtualIPControlGroup = ({ - Ingress IP{ipFieldsSuffix}{' '} - + Ingress IP } name="ingressVip" @@ -191,8 +188,7 @@ export const VirtualIPControlGroup = ({ - API IP{ipFieldsSuffix}{' '} - + API IP } name="apiVips.0.ip" @@ -208,8 +204,7 @@ export const VirtualIPControlGroup = ({ name="ingressVips.0.ip" label={ <> - Ingress IP{ipFieldsSuffix}{' '} - + Ingress IP } helperText={ipHelperText} diff --git a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/networkConfigurationValidation.ts b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/networkConfigurationValidation.ts index d7dab3f0e9..565b261e2d 100644 --- a/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/networkConfigurationValidation.ts +++ b/libs/ui-lib/lib/ocm/components/clusterConfiguration/networkConfiguration/networkConfigurationValidation.ts @@ -34,7 +34,7 @@ export const getNetworkInitialValues = ( isClusterManagedNetworkingUnsupported: boolean, ): NetworkConfigurationValues => { const isSNOCluster = isSNO(cluster); - const isDualStackType = isDualStack(cluster); + const isDualStackType = isDualStack({ ...cluster, openshiftVersion: cluster.openshiftVersion }); return { apiVips: cluster.apiVips, @@ -54,18 +54,25 @@ export const getNetworkInitialValues = ( cluster.clusterNetworks || (isDualStackType ? defaultNetworkValues.clusterNetworksDualstack - : defaultNetworkValues.clusterNetworksIpv4), + : defaultNetworkValues.clusterNetworksIpv4?.map((network) => ({ + ...network, + clusterId: cluster.id, + }))), serviceNetworks: cluster.serviceNetworks || (isDualStackType ? defaultNetworkValues.serviceNetworksDualstack - : defaultNetworkValues.serviceNetworksIpv4), + : defaultNetworkValues.serviceNetworksIpv4?.map((network) => ({ + ...network, + clusterId: cluster.id, + }))), }; }; export const getNetworkConfigurationValidationSchema = ( initialValues: NetworkConfigurationValues, hostSubnets: HostSubnets, + openshiftVersion?: string, ) => Yup.lazy((values: NetworkConfigurationValues) => Yup.object().shape({ @@ -85,7 +92,7 @@ export const getNetworkConfigurationValidationSchema = ( otherwise: () => values.machineNetworks && values.machineNetworks?.length >= 2 ? machineNetworksValidationSchema.concat( - dualStackValidationSchema('machine networks'), + dualStackValidationSchema('machine networks', openshiftVersion), ) : Yup.array(), }), @@ -94,7 +101,9 @@ export const getNetworkConfigurationValidationSchema = ( then: () => clusterNetworksValidationSchema.concat(IPv4ValidationSchema), otherwise: () => values.clusterNetworks && values.clusterNetworks?.length >= 2 - ? clusterNetworksValidationSchema.concat(dualStackValidationSchema('cluster network')) + ? clusterNetworksValidationSchema.concat( + dualStackValidationSchema('cluster network', openshiftVersion), + ) : Yup.array(), }), serviceNetworks: serviceNetworkValidationSchema.when('stackType', { @@ -102,7 +111,9 @@ export const getNetworkConfigurationValidationSchema = ( then: () => serviceNetworkValidationSchema.concat(IPv4ValidationSchema), otherwise: () => values.serviceNetworks && values.serviceNetworks?.length >= 2 - ? serviceNetworkValidationSchema.concat(dualStackValidationSchema('service network')) + ? serviceNetworkValidationSchema.concat( + dualStackValidationSchema('service network', openshiftVersion), + ) : Yup.array(), }), }), diff --git a/libs/ui-lib/lib/ocm/components/clusterConfiguration/review/ReviewNetworkingTable.tsx b/libs/ui-lib/lib/ocm/components/clusterConfiguration/review/ReviewNetworkingTable.tsx index 24564173ff..562e25b024 100644 --- a/libs/ui-lib/lib/ocm/components/clusterConfiguration/review/ReviewNetworkingTable.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterConfiguration/review/ReviewNetworkingTable.tsx @@ -58,7 +58,16 @@ export const ReviewNetworkingTable = ({ cluster }: { cluster: Cluster }) => { )), props: { 'data-testid': 'machine-networks' }, }, - isDualStack(cluster) ? { title: 'Primary' } : { title: '' }, + isDualStack({ ...cluster, openshiftVersion: cluster.openshiftVersion }) + ? { + title: cluster.machineNetworks?.map((network, index) => ( + + {index === 0 ? 'Primary' : 'Secondary'} +
+
+ )), + } + : { title: '' }, ], }); @@ -106,7 +115,14 @@ export const ReviewNetworkingTable = ({ cluster }: { cluster: Cluster }) => { )), props: { 'data-testid': 'cluster-network-cidr' }, }, - isDualStack(cluster) && { title: 'Primary' }, + isDualStack({ ...cluster, openshiftVersion: cluster.openshiftVersion }) && { + title: cluster.clusterNetworks?.map((network, index) => ( + + {index === 0 ? 'Primary' : 'Secondary'} +
+
+ )), + }, ].filter(Boolean), }, { @@ -122,7 +138,14 @@ export const ReviewNetworkingTable = ({ cluster }: { cluster: Cluster }) => { )), props: { 'data-testid': 'cluster-network-prefix' }, }, - isDualStack(cluster) && { title: 'Primary' }, + isDualStack({ ...cluster, openshiftVersion: cluster.openshiftVersion }) && { + title: cluster.clusterNetworks?.map((network, index) => ( + + {index === 0 ? 'Primary' : 'Secondary'} +
+
+ )), + }, ].filter(Boolean), }, { @@ -138,7 +161,14 @@ export const ReviewNetworkingTable = ({ cluster }: { cluster: Cluster }) => { )), props: { 'data-testid': 'service-network-cidr' }, }, - isDualStack(cluster) && { title: 'Primary' }, + isDualStack({ ...cluster, openshiftVersion: cluster.openshiftVersion }) && { + title: cluster.serviceNetworks?.map((network, index) => ( + + {index === 0 ? 'Primary' : 'Secondary'} +
+
+ )), + }, ].filter(Boolean), }, { diff --git a/libs/ui-lib/lib/ocm/components/clusterDetail/ClusterProperties.test.ts b/libs/ui-lib/lib/ocm/components/clusterDetail/ClusterProperties.test.ts new file mode 100644 index 0000000000..dbba0e32ea --- /dev/null +++ b/libs/ui-lib/lib/ocm/components/clusterDetail/ClusterProperties.test.ts @@ -0,0 +1,215 @@ +import { test, describe, expect } from 'vitest'; +import { getStackTypeLabel } from './ClusterProperties'; +import { Cluster } from '@openshift-assisted/types/assisted-installer-service'; + +const createCluster = (overrides: Partial = {}): Cluster => + ({ + id: 'test-cluster', + name: 'test-cluster', + kind: 'Cluster', + href: '/api/assisted-install/v2/clusters/test-cluster', + openshiftVersion: '4.11', + status: 'ready', + statusInfo: 'Cluster is ready', + machineNetworks: [], + clusterNetworks: [], + serviceNetworks: [], + ...overrides, + } as Cluster); + +const createMachineNetwork = (cidr: string) => ({ cidr, clusterId: 'test' }); +const createClusterNetwork = (cidr: string, hostPrefix: number) => ({ + cidr, + hostPrefix, + clusterId: 'test', +}); +const createServiceNetwork = (cidr: string) => ({ cidr, clusterId: 'test' }); + +describe('getStackTypeLabel', () => { + describe('OCP < 4.12 (legacy behavior)', () => { + test('returns "Dual-stack" for valid dual stack with IPv4 primary', () => { + const cluster = createCluster({ + openshiftVersion: '4.11', + machineNetworks: [ + createMachineNetwork('192.168.1.0/24'), + createMachineNetwork('2001:db8::/64'), + ], + clusterNetworks: [ + createClusterNetwork('10.128.0.0/14', 23), + createClusterNetwork('fd01::/48', 64), + ], + serviceNetworks: [ + createServiceNetwork('172.30.0.0/16'), + createServiceNetwork('fd02::/112'), + ], + }); + + expect(getStackTypeLabel(cluster)).toBe('Dual-stack'); + }); + + test('returns "IPv4" for single stack', () => { + const cluster = createCluster({ + openshiftVersion: '4.11', + machineNetworks: [createMachineNetwork('192.168.1.0/24')], + clusterNetworks: [createClusterNetwork('10.128.0.0/14', 23)], + serviceNetworks: [createServiceNetwork('172.30.0.0/16')], + }); + + expect(getStackTypeLabel(cluster)).toBe('IPv4'); + }); + + test('returns "IPv4" for IPv6 primary (invalid for OCP < 4.12)', () => { + const cluster = createCluster({ + openshiftVersion: '4.11', + machineNetworks: [ + createMachineNetwork('2001:db8::/64'), + createMachineNetwork('192.168.1.0/24'), + ], + clusterNetworks: [ + createClusterNetwork('fd01::/48', 64), + createClusterNetwork('10.128.0.0/14', 23), + ], + serviceNetworks: [ + createServiceNetwork('fd02::/112'), + createServiceNetwork('172.30.0.0/16'), + ], + }); + + expect(getStackTypeLabel(cluster)).toBe('IPv4'); + }); + }); + + describe('OCP >= 4.12 (new behavior)', () => { + test('returns "Dual-stack" for IPv4 primary and IPv6 secondary', () => { + const cluster = createCluster({ + openshiftVersion: '4.12', + machineNetworks: [ + createMachineNetwork('192.168.1.0/24'), + createMachineNetwork('2001:db8::/64'), + ], + clusterNetworks: [ + createClusterNetwork('10.128.0.0/14', 23), + createClusterNetwork('fd01::/48', 64), + ], + serviceNetworks: [ + createServiceNetwork('172.30.0.0/16'), + createServiceNetwork('fd02::/112'), + ], + }); + + expect(getStackTypeLabel(cluster)).toBe('Dual-stack'); + }); + + test('returns "Dual-stack" for IPv6 primary and IPv4 secondary', () => { + const cluster = createCluster({ + openshiftVersion: '4.12', + machineNetworks: [ + createMachineNetwork('2001:db8::/64'), + createMachineNetwork('192.168.1.0/24'), + ], + clusterNetworks: [ + createClusterNetwork('fd01::/48', 64), + createClusterNetwork('10.128.0.0/14', 23), + ], + serviceNetworks: [ + createServiceNetwork('fd02::/112'), + createServiceNetwork('172.30.0.0/16'), + ], + }); + + expect(getStackTypeLabel(cluster)).toBe('Dual-stack'); + }); + + test('returns "IPv4" for IPv6 primary and IPv6 secondary (not dual-stack)', () => { + const cluster = createCluster({ + openshiftVersion: '4.12', + machineNetworks: [ + createMachineNetwork('2001:db8::/64'), + createMachineNetwork('2001:db9::/64'), + ], + clusterNetworks: [ + createClusterNetwork('fd01::/48', 64), + createClusterNetwork('fd02::/48', 64), + ], + serviceNetworks: [createServiceNetwork('fd03::/112'), createServiceNetwork('fd04::/112')], + }); + + expect(getStackTypeLabel(cluster)).toBe('IPv4'); + }); + + test('returns "IPv4" for IPv4 primary and IPv4 secondary (not dual-stack)', () => { + const cluster = createCluster({ + openshiftVersion: '4.12', + machineNetworks: [ + createMachineNetwork('192.168.1.0/24'), + createMachineNetwork('10.0.0.0/24'), + ], + clusterNetworks: [ + createClusterNetwork('10.128.0.0/14', 23), + createClusterNetwork('172.16.0.0/14', 23), + ], + serviceNetworks: [ + createServiceNetwork('172.30.0.0/16'), + createServiceNetwork('172.31.0.0/16'), + ], + }); + + expect(getStackTypeLabel(cluster)).toBe('IPv4'); + }); + + test('returns "IPv4" for single stack', () => { + const cluster = createCluster({ + openshiftVersion: '4.12', + machineNetworks: [createMachineNetwork('192.168.1.0/24')], + clusterNetworks: [createClusterNetwork('10.128.0.0/14', 23)], + serviceNetworks: [createServiceNetwork('172.30.0.0/16')], + }); + + expect(getStackTypeLabel(cluster)).toBe('IPv4'); + }); + }); + + describe('edge cases', () => { + test('handles undefined openshiftVersion', () => { + const cluster = createCluster({ + openshiftVersion: undefined, + machineNetworks: [ + createMachineNetwork('192.168.1.0/24'), + createMachineNetwork('2001:db8::/64'), + ], + clusterNetworks: [ + createClusterNetwork('10.128.0.0/14', 23), + createClusterNetwork('fd01::/48', 64), + ], + serviceNetworks: [ + createServiceNetwork('172.30.0.0/16'), + createServiceNetwork('fd02::/112'), + ], + }); + + expect(getStackTypeLabel(cluster)).toBe('Dual-stack'); + }); + + test('handles empty networks', () => { + const cluster = createCluster({ + openshiftVersion: '4.12', + machineNetworks: [], + clusterNetworks: [], + serviceNetworks: [], + }); + + expect(getStackTypeLabel(cluster)).toBe('IPv4'); + }); + + test('handles undefined networks', () => { + const cluster = createCluster({ + openshiftVersion: '4.12', + machineNetworks: undefined, + clusterNetworks: undefined, + serviceNetworks: undefined, + }); + + expect(getStackTypeLabel(cluster)).toBe('IPv4'); + }); + }); +}); diff --git a/libs/ui-lib/lib/ocm/components/clusterDetail/ClusterProperties.tsx b/libs/ui-lib/lib/ocm/components/clusterDetail/ClusterProperties.tsx index dbc41d740f..324039fc13 100644 --- a/libs/ui-lib/lib/ocm/components/clusterDetail/ClusterProperties.tsx +++ b/libs/ui-lib/lib/ocm/components/clusterDetail/ClusterProperties.tsx @@ -41,7 +41,7 @@ export const getManagementType = ({ userManagedNetworking }: Cluster): string => }; export const getStackTypeLabel = (cluster: Cluster): string => - isDualStack(cluster) ? 'Dual-stack' : 'IPv4'; + isDualStack({ ...cluster, openshiftVersion: cluster.openshiftVersion }) ? 'Dual-stack' : 'IPv4'; export const getDiskEncryptionEnabledOnStatus = (diskEncryption: DiskEncryption['enableOn']) => { let diskEncryptionType = null; @@ -67,7 +67,7 @@ export const getDiskEncryptionEnabledOnStatus = (diskEncryption: DiskEncryption[ const ClusterProperties = ({ cluster, externalMode = false }: ClusterPropertiesProps) => { const { t } = useTranslation(); - const isDualStackType = isDualStack(cluster); + const isDualStackType = isDualStack({ ...cluster, openshiftVersion: cluster.openshiftVersion }); const featureSupportLevelContext = useNewFeatureSupportLevel(); const activeFeatureConfiguration = featureSupportLevelContext.activeFeatureConfiguration;