import React, { FC, useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { modelToGroupVersionKind, NamespaceModel } from '@kubevirt-ui-ext/kubevirt-api/console'; import { k8sCreate, K8sResourceKind, k8sUpdate, useK8sWatchResource, } from '@openshift-console/dynamic-plugin-sdk'; import { ActionGroup, Alert, AlertVariant, Button, ButtonVariant, Card, CardBody, CardTitle, Dropdown, DropdownItem, DropdownList, Form, FormGroup, FormSection, Grid, GridItem, MenuToggle, PageSection, TextInput, } from '@patternfly/react-core'; import FormGroupHelperText from '@utils/components/FormGroupHelperText/FormGroupHelperText'; import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; import { BGPCloudConfigurationModel, BGPRoutingModel } from '@utils/models'; import { BGPCloudConfiguration, BGPRouting, isCIDR, parseSelector } from '@utils/resources/bgp'; import { getName, resourcePathFromModel } from '@utils/resources/shared'; export type BGPFormProps = { formData: T; onChange: (data: T) => void }; type FieldProps = { error?: string; help?: string; id: string; label: string; placeholder?: string; register: any; required?: boolean; validate?: (value: number | string) => boolean | string; }; const Field: FC = ({ error, help, id, label, placeholder, register, required, validate, }) => ( {error || help} ); const BGPSelect: FC<{ help?: string; id: string; label: string; onChange: (value: string) => void; options: Array<{ label: string; value: string }>; required?: boolean; value: string; }> = ({ help, id, label, onChange, options, required, value }) => { const [isOpen, setIsOpen] = useState(false); const selected = options.find((option) => option.value === value); return ( ( setIsOpen(!isOpen)} ref={toggleRef}> {selected?.label || value} )} > {options.map((option) => ( { onChange(option.value); setIsOpen(false); }} > {option.label} ))} {help} ); }; type CloudValues = { livenessDetection: 'bfd' | 'bgp-keepalive'; localASN: number | string; platform: 'AWS' | 'Azure' | 'GCP' | 'Manual'; routerSelectorKey: string; routerSelectorValue: string; } & Record; const firstSelector = (selector?: Record) => Object.entries(selector || {})[0] || ['', '']; const cloudPayload = ( data: CloudValues, original?: BGPCloudConfiguration, ): BGPCloudConfiguration => { const spec: BGPCloudConfiguration['spec'] = { ...original?.spec, bgp: { ...original?.spec?.bgp, livenessDetection: data.livenessDetection, localASN: Number(data.localASN), }, platform: data.platform, routerNodeSelector: parseSelector(`${data.routerSelectorKey}=${data.routerSelectorValue}`), }; if (data.platform === 'AWS') spec.aws = { ...original?.spec?.aws, region: data.awsRegion, routeServerIDs: data.awsRouteServerIDs .split(',') .map((id: string) => id.trim()) .filter(Boolean), }; if (data.platform === 'Azure') spec.azure = { ...original?.spec?.azure, resourceGroup: data.azureResourceGroup, routeServerName: data.azureRouteServerName, subscriptionID: data.azureSubscriptionID, }; if (data.platform === 'GCP') spec.gcp = { ...original?.spec?.gcp, cloudRouterName: data.cloudRouterName, enableNestedVirtualization: data.enableNestedVirtualization, ncc: { hubName: data.gcpNCCHubName, spokePrefix: data.gcpNCCSpokePrefix }, project: data.gcpProject, region: data.gcpRegion, }; if (data.platform === 'Manual') spec.bgp.peerGroups = [ { neighbors: [ { address: data.neighborAddress, ebgpMultiHop: data.ebgpMultiHop, remoteASN: Number(data.remoteASN), }, ], nodeSelector: parseSelector(`${data.peerSelectorKey}=${data.peerSelectorValue}`), }, ]; return { apiVersion: 'networking.openshift.io/v1beta1', kind: 'BGPCloudConfiguration', metadata: original?.metadata || { name: 'cluster' }, spec, }; }; export const BGPCloudConfigurationForm: FC> = ({ formData, onChange, }) => { const { t } = useNetworkingTranslation(); const navigate = useNavigate(); const [error, setError] = useState(); const originalResource = useRef(formData).current; const [routerKey, routerValue] = firstSelector(formData.spec?.routerNodeSelector); const [peerKey, peerValue] = firstSelector(formData.spec?.bgp?.peerGroups?.[0]?.nodeSelector); const neighbor = formData.spec?.bgp?.peerGroups?.[0]?.neighbors?.[0]; const methods = useForm({ defaultValues: { awsRegion: formData.spec?.aws?.region || '', awsRouteServerIDs: formData.spec?.aws?.routeServerIDs?.join(', ') || '', azureResourceGroup: formData.spec?.azure?.resourceGroup || '', azureRouteServerName: formData.spec?.azure?.routeServerName || '', azureSubscriptionID: formData.spec?.azure?.subscriptionID || '', cloudRouterName: formData.spec?.gcp?.cloudRouterName || '', ebgpMultiHop: neighbor?.ebgpMultiHop || false, enableNestedVirtualization: formData.spec?.gcp?.enableNestedVirtualization ?? true, gcpNCCHubName: formData.spec?.gcp?.ncc?.hubName || '', gcpNCCSpokePrefix: formData.spec?.gcp?.ncc?.spokePrefix || '', gcpProject: formData.spec?.gcp?.project || '', gcpRegion: formData.spec?.gcp?.region || '', livenessDetection: formData.spec?.bgp?.livenessDetection || 'bgp-keepalive', localASN: formData.spec?.bgp?.localASN || '', neighborAddress: neighbor?.address || '', peerSelectorKey: peerKey, peerSelectorValue: peerValue, platform: formData.spec?.platform || 'AWS', remoteASN: neighbor?.remoteASN || '', routerSelectorKey: routerKey, routerSelectorValue: routerValue, }, mode: 'onChange', }); const { formState: { errors, isSubmitting }, handleSubmit, register, watch, } = methods; const values = watch(); const platform = values.platform; useEffect( () => onChange(cloudPayload(values, originalResource)), [onChange, originalResource, values], ); const submit = async (data: CloudValues) => { try { const payload = cloudPayload(data, originalResource); await (originalResource.metadata?.resourceVersion ? k8sUpdate({ data: payload, model: BGPCloudConfigurationModel, name: getName(originalResource), }) : k8sCreate({ data: payload, model: BGPCloudConfigurationModel })); navigate(resourcePathFromModel(BGPCloudConfigurationModel, 'cluster')); } catch (err) { setError(err as Error); } }; return (
{t('This cluster-wide singleton is always named cluster.')} methods.setValue('livenessDetection', value as CloudValues['livenessDetection'], { shouldDirty: true, shouldValidate: true, }) } options={[ { label: t('BGP keepalive'), value: 'bgp-keepalive' }, { label: 'BFD', value: 'bfd' }, ]} required value={values.livenessDetection} /> methods.setValue('platform', value as CloudValues['platform'], { shouldDirty: true, shouldValidate: true, }) } options={['AWS', 'Azure', 'GCP', 'Manual'].map((value) => ({ label: value, value }))} required value={values.platform} /> {t('{{platform}} configuration', { platform })} {platform === 'AWS' && ( )} {platform === 'Azure' && ( )} {platform === 'GCP' && ( )} {platform === 'Manual' && ( )} {t( 'Router node selectors identify nodes that establish BGP sessions. Changing them can affect peering and routing availability.', )} {error && ( {error.message} )}
); }; export const BGPRoutingForm: FC> = ({ formData, onChange }) => { const { t } = useNetworkingTranslation(); const navigate = useNavigate(); const [error, setError] = useState(); const originalResource = useRef(formData).current; const methods = useForm({ defaultValues: { name: getName(formData) || '', network: formData.spec?.network?.name || '', subnet1: formData.spec?.network?.subnets?.[0] || '', subnet2: formData.spec?.network?.subnets?.[1] || '', }, mode: 'onChange', }); const { formState: { errors, isSubmitting }, handleSubmit, register, watch, } = methods; const values = watch(); const [matchingNamespaces, matchingNamespacesLoaded] = useK8sWatchResource( values.network ? { groupVersionKind: modelToGroupVersionKind(NamespaceModel), isList: true, selector: { matchLabels: { 'cluster-udn': values.network, 'k8s.ovn.org/primary-user-defined-network': '', }, }, } : null, ); let subnetError: string; if (values.subnet1 && !isCIDR(values.subnet1)) subnetError = t('Enter a valid IPv4 or IPv6 CIDR.'); else if (values.subnet2 && !isCIDR(values.subnet2)) subnetError = t('Enter a valid IPv4 or IPv6 CIDR.'); useEffect( () => onChange({ apiVersion: 'networking.openshift.io/v1beta1', kind: 'BGPRouting', metadata: { ...originalResource.metadata, name: values.name }, spec: { network: { name: values.network, subnets: [values.subnet1, values.subnet2].filter(Boolean), }, }, }), [onChange, originalResource, values], ); const submit = async (data: any) => { if (subnetError) return; try { const payload: BGPRouting = { apiVersion: 'networking.openshift.io/v1beta1', kind: 'BGPRouting', metadata: { ...originalResource.metadata, name: data.name }, spec: { ...originalResource.spec, network: { name: data.network, subnets: [data.subnet1, data.subnet2].filter(Boolean) }, }, }; await (originalResource.metadata?.resourceVersion ? k8sUpdate({ data: payload, model: BGPRoutingModel, name: getName(originalResource), }) : k8sCreate({ data: payload, model: BGPRoutingModel })); navigate(resourcePathFromModel(BGPRoutingModel, data.name)); } catch (err) { setError(err as Error); } }; return (

{matchingNamespacesLoaded && matchingNamespaces?.length ? t('Found {{count}} matching namespaces: {{namespaces}}.', { count: matchingNamespaces.length, namespaces: matchingNamespaces .map((namespace) => namespace.metadata?.name) .filter(Boolean) .join(', '), }) : t('No namespaces currently match these labels.')}

{t('To add more namespaces, enter each label in a separate key/value row:')}

{`cluster-udn=${values.network || ''}`}
k8s.ovn.org/primary-user-defined-network
{error && ( {error.message} )}
); };