From fca2e7f4a11ac9178bbfa6f02c0d9b995e44e420 Mon Sep 17 00:00:00 2001 From: Timothy Asir Jeyasingh Date: Fri, 17 Jul 2026 16:57:46 +0530 Subject: [PATCH 1/2] Add Submariner pre-pair validation wizard to Create DRPolicy Validate ACM-managed and upstream Submariner before policy creation, surface Globalnet warnings, and align the Data Foundation flow with the four-step UX. Signed-off-by: Timothy Asir Jeyasingh --- locales/en/plugin__odf-console.json | 28 + .../create-dr-policy/CreateDRPolicyForm.tsx | 391 ++++--------- .../create-dr-policy/clusters-step.tsx | 169 ++++++ .../configure-cluster-pair-step.tsx | 37 ++ .../create-dr-policy/create-dr-policy.scss | 8 +- .../create-dr-policy.spec.tsx | 55 +- .../components/create-dr-policy/footer.tsx | 74 +++ .../create-dr-policy/policy-step.tsx | 126 +++++ .../pre-pair-network-validation.tsx | 258 +++++++++ .../review-dr-policy-step.tsx | 96 ++++ packages/mco/constants/disaster-recovery.ts | 14 + packages/mco/constants/doc.tsx | 2 + packages/mco/constants/index.ts | 2 +- packages/mco/constants/submariner.ts | 56 ++ packages/mco/hooks/index.ts | 1 + packages/mco/hooks/mco-resources.ts | 35 +- .../hooks/use-pre-pair-network-validation.ts | 279 ++++++++++ packages/mco/types/index.ts | 1 + packages/mco/types/submariner.ts | 42 ++ packages/mco/utils/index.ts | 1 + packages/mco/utils/submariner-health.spec.ts | 523 ++++++++++++++++++ packages/mco/utils/submariner-health.ts | 423 ++++++++++++++ packages/odf/components/utils/cidr-utils.ts | 56 +- packages/shared/src/selectors/k8s.ts | 29 +- packages/shared/src/utils/cidr.spec.ts | 94 ++++ packages/shared/src/utils/cidr.ts | 71 +++ packages/shared/src/utils/index.ts | 1 + 27 files changed, 2526 insertions(+), 346 deletions(-) create mode 100644 packages/mco/components/create-dr-policy/clusters-step.tsx create mode 100644 packages/mco/components/create-dr-policy/configure-cluster-pair-step.tsx create mode 100644 packages/mco/components/create-dr-policy/footer.tsx create mode 100644 packages/mco/components/create-dr-policy/policy-step.tsx create mode 100644 packages/mco/components/create-dr-policy/pre-pair-network-validation.tsx create mode 100644 packages/mco/components/create-dr-policy/review-dr-policy-step.tsx create mode 100644 packages/mco/constants/submariner.ts create mode 100644 packages/mco/hooks/use-pre-pair-network-validation.ts create mode 100644 packages/mco/types/submariner.ts create mode 100644 packages/mco/utils/submariner-health.spec.ts create mode 100644 packages/mco/utils/submariner-health.ts create mode 100644 packages/shared/src/utils/cidr.spec.ts create mode 100644 packages/shared/src/utils/cidr.ts diff --git a/locales/en/plugin__odf-console.json b/locales/en/plugin__odf-console.json index 715400de8..c97fdaf2d 100644 --- a/locales/en/plugin__odf-console.json +++ b/locales/en/plugin__odf-console.json @@ -254,6 +254,7 @@ "Advanced configuration": "Advanced configuration", "Advanced settings": "Advanced settings", "Advanced Settings": "Advanced Settings", + "Advanced settings:": "Advanced settings:", "Advanced Subscription": "Advanced Subscription", "After the LocalVolumeSet is created you won't be able to edit it.": "After the LocalVolumeSet is created you won't be able to edit it.", "Aggregate resource requirement for {{resourceProfile}} mode not met": "Aggregate resource requirement for {{resourceProfile}} mode not met", @@ -506,6 +507,7 @@ "Check": "Check", "Check potential reasons": "Check potential reasons", "check unsuccessful on the {{clusterName}}:": "check unsuccessful on the {{clusterName}}:", + "Checking cluster network connectivity...": "Checking cluster network connectivity...", "Checking dependencies...": "Checking dependencies...", "Checking Local Storage Operator installation": "Checking Local Storage Operator installation", "Checks cannot be performed for the {{clusterName}}:": "Checks cannot be performed for the {{clusterName}}:", @@ -554,7 +556,10 @@ "Cluster name (ID)": "Cluster name (ID)", "cluster name search": "cluster name search", "cluster name search button": "cluster name search button", + "Cluster network - Skipped": "Cluster network - Skipped", "Cluster network CIDR": "Cluster network CIDR", + "Cluster network configuration in progress": "Cluster network configuration in progress", + "Cluster network connected": "Cluster network connected", "Cluster Network Interface": "Cluster Network Interface", "Cluster operator": "Cluster operator", "Cluster Status": "Cluster Status", @@ -585,8 +590,10 @@ "ConfigMap": "ConfigMap", "ConfigMaps": "ConfigMaps", "Configuration": "Configuration", + "Configure": "Configure", "Configure and recover your business critical applications in event of any disaster.": "Configure and recover your business critical applications in event of any disaster.", "Configure Ceph Monitor": "Configure Ceph Monitor", + "Configure cluster pair": "Configure cluster pair", "Configure connection network": "Configure connection network", "Configure Data Foundation": "Configure Data Foundation", "Configure default RBD StorageClass to avoid adding manual annotations within a StorageClass and selecting a specific StorageClass when making storage requests or provisions in your PVCs.": "Configure default RBD StorageClass to avoid adding manual annotations within a StorageClass and selecting a specific StorageClass when making storage requests or provisions in your PVCs.", @@ -605,6 +612,7 @@ "Confirm name": "Confirm name", "Confirm remove": "Confirm remove", "Confirm save changes?": "Confirm save changes?", + "Confirm the disaster recovery policy details before creating the policy.": "Confirm the disaster recovery policy details before creating the policy.", "Connect": "Connect", "Connect an external storage system to get started. Add <2>IBM FlashSystem, IBM Scale or Red Hat Ceph Storage to begin managing it.": "Connect an external storage system to get started. Add <2>IBM FlashSystem, IBM Scale or Red Hat Ceph Storage to begin managing it.", "Connect and create": "Connect and create", @@ -671,6 +679,7 @@ "Create BucketClass": "Create BucketClass", "Create CORS rule": "Create CORS rule", "Create DRPolicy": "Create DRPolicy", + "Create DRPolicy steps": "Create DRPolicy steps", "Create folder": "Create folder", "Create IAM user": "Create IAM user", "Create IBM Scale (CNSA)": "Create IBM Scale (CNSA)", @@ -790,6 +799,7 @@ "Define where to sync or replicate your application volumes and Kubernetes object using a disaster recovery policy.": "Define where to sync or replicate your application volumes and Kubernetes object using a disaster recovery policy.", "Defines the object-store service and the bucket provisioner.": "Defines the object-store service and the bucket provisioner.", "Degraded": "Degraded", + "Degraded - Cluster unhealthy": "Degraded - Cluster unhealthy", "delete": "delete", "Delete": "Delete", "Delete (expire current versions) {{ days }} days after creation.": "Delete (expire current versions) {{ days }} days after creation.", @@ -850,6 +860,7 @@ "Deploying the application on the target cluster.": "Deploying the application on the target cluster.", "Deployment": "Deployment", "Deployment details": "Deployment details", + "Deployment is not using submariner. Selected cluster pairs lack submariner addon": "Deployment is not using submariner. Selected cluster pairs lack submariner addon", "Deployment is unavailable": "Deployment is unavailable", "Deployment Name": "Deployment Name", "Deployment type": "Deployment type", @@ -992,6 +1003,7 @@ "Enables mirroring/replication between two selected clusters, ensuring failover or relocation between the two clusters in the event of an outage or planned maintenance.": "Enables mirroring/replication between two selected clusters, ensuring failover or relocation between the two clusters in the event of an outage or planned maintenance.", "Enabling compression may result in little or no space savings for encrypted or random data. Also, enabling compression may have an impact on I/O performance.": "Enabling compression may result in little or no space savings for encrypted or random data. Also, enabling compression may have an impact on I/O performance.", "Enabling CORS will allow browsers to request resources from the bucket.": "Enabling CORS will allow browsers to request resources from the bucket.", + "Enabling...": "Enabling...", "Encryption": "Encryption", "Encryption CA certificate": "Encryption CA certificate", "Encryption for all data passing over the network": "Encryption for all data passing over the network", @@ -1179,6 +1191,11 @@ "Global": "Global", "Global (Bucket-wide)": "Global (Bucket-wide)", "Global rule scope selected": "Global rule scope selected", + "Globalnet": "Globalnet", + "Globalnet is off": "Globalnet is off", + "Globalnet is on. Cluster networks do not overlap": "Globalnet is on. Cluster networks do not overlap", + "Globalnet is on. Cluster networks have overlapping Pod or Service CIDR.": "Globalnet is on. Cluster networks have overlapping Pod or Service CIDR.", + "Globalnet is required as CIDRs overlap. Broker is missing": "Globalnet is required as CIDRs overlap. Broker is missing", "Go back": "Go back", "Go To PVC List": "Go To PVC List", "Go to the cloned repository for {{appName}} on the failed cluster {{cluster}} and delete the application.": "Go to the cloned repository for {{appName}} on the failed cluster {{cluster}} and delete the application.", @@ -1828,6 +1845,8 @@ "Raw Capacity": "Raw Capacity", "Raw capacity is the absolute total disk space available to the array subsystem.": "Raw capacity is the absolute total disk space available to the array subsystem.", "Raw capacity shows the total physical capacity from all the storage pools in the storage system.": "Raw capacity shows the total physical capacity from all the storage pools in the storage system.", + "RBD image flatten disabled": "RBD image flatten disabled", + "RBD image flatten enabled": "RBD image flatten enabled", "Read and Write NamespaceStore ": "Read and Write NamespaceStore ", "Read and write NamespaceStore : ": "Read and write NamespaceStore : ", "Read NamespaceStores": "Read NamespaceStores", @@ -1931,6 +1950,8 @@ "Running": "Running", "Running checks to ensure that the selected managed cluster meets all necessary conditions so it can enroll in a Disaster Recovery policy.": "Running checks to ensure that the selected managed cluster meets all necessary conditions so it can enroll in a Disaster Recovery policy.", "S3 bucket for {{name}}": "S3 bucket for {{name}}", + "S3 profile (cluster 1):": "S3 profile (cluster 1):", + "S3 profile (cluster 2):": "S3 profile (cluster 2):", "S3 profile name": "S3 profile name", "S3 Vector": "S3 Vector", "SAN-based storage": "SAN-based storage", @@ -1987,6 +2008,7 @@ "Select a Pool": "Select a Pool", "Select a profile to customise the performance of the Data Foundation cluster to meet your requirements.": "Select a profile to customise the performance of the Data Foundation cluster to meet your requirements.", "Select a recipe": "Select a recipe", + "Select a replication policy type and interval.": "Select a replication policy type and interval.", "Select a secret for registry authentication.": "Select a secret for registry authentication.", "Select a single NamespaceStore that defines the write targets of the namespace bucket.": "Select a single NamespaceStore that defines the write targets of the namespace bucket.", "Select a storage system for your workloads.": "Select a storage system for your workloads.", @@ -2182,6 +2204,9 @@ "StorageConsumer Name": "StorageConsumer Name", "StorageConsumers": "StorageConsumers", "Stretch Cluster": "Stretch Cluster", + "Submariner": "Submariner", + "Submariner broker configuration not found": "Submariner broker configuration not found", + "Submariner is not installed on one or both selected cluster pairs": "Submariner is not installed on one or both selected cluster pairs", "Subpath": "Subpath", "Subscription": "Subscription", "Subscription deprecated": "Subscription deprecated", @@ -2252,6 +2277,7 @@ "The DR Policy validation failed": "The DR Policy validation failed", "The endpoint is not a valid IP address": "The endpoint is not a valid IP address", "The endpoint server name. Useful when the KMIP endpoint does not have a DNS entry.": "The endpoint server name. Useful when the KMIP endpoint does not have a DNS entry.", + "The first time a policy is created between two clusters, the clusters must be paired together.": "The first time a policy is created between two clusters, the clusters must be paired together.", "The forward slash (\"/\") cannot be used.": "The forward slash (\"/\") cannot be used.", "The graph displays the average replication throughput inbound, by cluster, from all ApplicationSet and Subscription type applications. Applications that use file volumes are excluded in the replication throughput.": "The graph displays the average replication throughput inbound, by cluster, from all ApplicationSet and Subscription type applications. Applications that use file volumes are excluded in the replication throughput.", "The graph displays the total number of block volumes inbound snapshots, by cluster, from all ApplicationSet and Subscription type applications. Applications that use file volumes are excluded in the total snapshot count.": "The graph displays the total number of block volumes inbound snapshots, by cluster, from all ApplicationSet and Subscription type applications. Applications that use file volumes are excluded in the total snapshot count.", @@ -2429,6 +2455,8 @@ "Uploading files to the bucket is in progress": "Uploading files to the bucket is in progress", "Uploads": "Uploads", "Upon rotation, the existing signing key will be revoked and replaced with a new one.": "Upon rotation, the existing signing key will be revoked and replaced with a new one.", + "Upstream Submariner detected. Advanced validation is only available for ACM-managed Submariner.": "Upstream Submariner detected. Advanced validation is only available for ACM-managed Submariner.", + "Upstream Submariner detected. Advanced validation is only available for ACM-managed Submariner. The selected clusters use different Submariner installation methods.": "Upstream Submariner detected. Advanced validation is only available for ACM-managed Submariner. The selected clusters use different Submariner installation methods.", "URL of the image registry.": "URL of the image registry.", "Use a predefined policy configuration?": "Use a predefined policy configuration?", "Use an existing StorageClass": "Use an existing StorageClass", diff --git a/packages/mco/components/create-dr-policy/CreateDRPolicyForm.tsx b/packages/mco/components/create-dr-policy/CreateDRPolicyForm.tsx index 6b97e264b..d2b8968c0 100644 --- a/packages/mco/components/create-dr-policy/CreateDRPolicyForm.tsx +++ b/packages/mco/components/create-dr-policy/CreateDRPolicyForm.tsx @@ -1,59 +1,42 @@ import * as React from 'react'; -import { getMajorVersion } from '@odf/mco/utils'; +import { usePrePairNetworkValidation } from '@odf/mco/hooks'; +import { getMajorVersion, shouldRunPrePairValidation } from '@odf/mco/utils'; import { ACM_DEFAULT_DOC_VERSION, - DOC_VERSION, DRClusterModel, MirrorPeerModel, - tpsDoc, useDocVersion, } from '@odf/shared'; import { StatusBox } from '@odf/shared/generic/status-box'; import { useFetchCsv } from '@odf/shared/hooks/use-fetch-csv'; import { getName } from '@odf/shared/selectors'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; -import { ExternalLink, referenceForModel } from '@odf/shared/utils'; +import { referenceForModel } from '@odf/shared/utils'; import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; -import { Trans } from 'react-i18next'; -import { - ActionGroup, - Alert, - AlertVariant, - Button, - ButtonVariant, - Checkbox, - ExpandableSection, - Form, - FormGroup, - FormHelperText, - HelperText, - HelperTextItem, - TextInput, -} from '@patternfly/react-core'; +import { FormGroup, Wizard, WizardStep } from '@patternfly/react-core'; import { ACM_OPERATOR_SPEC_NAME, acmDocHome, + acmSubmarinerDoc, BackendType, + CreateDRPolicyStepNames, + CreateDRPolicyWizardSteps, MAX_ALLOWED_CLUSTERS, ODFMCO_OPERATOR, - ReplicationType, } from '../../constants'; import '../../style.scss'; import { DRClusterKind, MirrorPeerKind, S3StoreProfile } from '../../types'; import { fetchRamenS3Profiles } from '../../utils/tps-payload-creator'; -import { - ClusterS3BucketDetailsForm, - S3Details, -} from './add-s3-bucket-details/s3-bucket-details-form'; +import { S3Details } from './add-s3-bucket-details/s3-bucket-details-form'; +import { ClustersStep } from './clusters-step'; +import { ConfigureClusterPairStep } from './configure-cluster-pair-step'; import './create-dr-policy.scss'; -import { SelectClusterList } from './select-cluster-list'; -import { SelectReplicationBackend } from './select-replication-backend/select-replication-backend'; -import { SelectReplicationType } from './select-replication-type'; -import { SelectedClusterValidation } from './selected-cluster-validator'; -import ThirdPartyStorageWarning from './third-party-storage-alert'; +import { CreateDRPolicyWizardFooter } from './footer'; +import { PolicyStep } from './policy-step'; +import { PrePairNetworkValidation } from './pre-pair-network-validation'; +import { ReviewDRPolicyStep } from './review-dr-policy-step'; import { createPolicyPromises } from './utils/k8s-utils'; import { - DRPolicyAction, DRPolicyActionType, drPolicyInitialState, drPolicyReducer, @@ -76,13 +59,11 @@ const areS3DetailsFormatValid = (d: S3Details): boolean => isFilled(d.s3ProfileName) && isValidS3ProfileName(d.s3ProfileName); -export const validateDRPolicyInputs = ( +const validateClusterInputs = ( state: DRPolicyState, allDRClustersExist = false ): boolean => { const { - policyName, - replicationType, selectedClusters, isClusterSelectionValid, replicationBackend, @@ -92,10 +73,7 @@ export const validateDRPolicyInputs = ( } = state; const baseValid = - isFilled(policyName) && - !!replicationType && - isClusterSelectionValid && - selectedClusters.length === MAX_ALLOWED_CLUSTERS; + isClusterSelectionValid && selectedClusters.length === MAX_ALLOWED_CLUSTERS; if (!baseValid) return false; @@ -114,50 +92,15 @@ export const validateDRPolicyInputs = ( return true; }; -const AdvancedSettings: React.FC = ({ - enableRBDImageFlatten, - dispatch, -}) => { - const { t } = useCustomTranslation(); - - const handleRBDImageFlattenOnChange = (checked: boolean) => { - dispatch({ - type: DRPolicyActionType.SET_RBD_IMAGE_FLATTEN, - payload: checked, - }); - }; +const validatePolicyInputs = (state: DRPolicyState): boolean => + isFilled(state.policyName) && !!state.replicationType; - return ( - - - handleRBDImageFlattenOnChange(checked) - } - id="flat-image-checkbox" - name="flat-image-checkbox" - /> - - Before choosing this option, read the section - - Creating Disaster Recovery Policy on Hub cluster chapter of - Regional-DR solution guide - - to understand the impact and limitations of this feature. - - } - variant={AlertVariant.warning} - isInline - /> - - ); -}; +export const validateDRPolicyInputs = ( + state: DRPolicyState, + allDRClustersExist = false +): boolean => + validatePolicyInputs(state) && + validateClusterInputs(state, allDRClustersExist); const convertS3ProfileToDetails = ( profile: S3StoreProfile, @@ -190,8 +133,10 @@ export const CreateDRPolicyForm: React.FC = ({ drPolicyReducer, drPolicyInitialState ); - const [errorMessage, setErrorMessage] = React.useState(''); + const [s3ErrorMessage, setS3ErrorMessage] = React.useState(''); + const [createErrorMessage, setCreateErrorMessage] = React.useState(''); const [isLoading, setIsLoading] = React.useState(false); + const [step, setStep] = React.useState(CreateDRPolicyWizardSteps.Clusters); const [mirrorPeers, mirrorPeerLoaded, mirrorPeerLoadError] = useK8sWatchResource({ @@ -234,14 +179,13 @@ export const CreateDRPolicyForm: React.FC = ({ drClustersLoadError, ]); - // Note: Pre-selection is handled by SelectClusterList via preSelectedClusterNames prop - React.useEffect(() => { const loadS3ProfileDetails = async () => { if ( state.replicationBackend === BackendType.ThirdParty && selectedDRClusters.length === MAX_ALLOWED_CLUSTERS ) { + setS3ErrorMessage(''); try { const ramenS3Profiles = await fetchRamenS3Profiles(); @@ -285,7 +229,7 @@ export const CreateDRPolicyForm: React.FC = ({ } } } catch (error) { - setErrorMessage( + setS3ErrorMessage( t('Failed to load S3 profile details: {{error}}', { error: (error as Error)?.message || JSON.stringify(error), }) @@ -299,28 +243,34 @@ export const CreateDRPolicyForm: React.FC = ({ const onCreate = async () => { try { + setCreateErrorMessage(''); setIsLoading(true); await createPolicyPromises(state, mirrorPeers, selectedDRClusters); onSuccess(); } catch (error) { setIsLoading(false); - setErrorMessage((error as Error)?.message || JSON.stringify(error)); + setCreateErrorMessage((error as Error)?.message || JSON.stringify(error)); } }; - const setPolicyName = (strVal: string) => - dispatch({ - type: DRPolicyActionType.SET_POLICY_NAME, - payload: strVal, - }); - const loaded = mirrorPeerLoaded && drClustersLoaded; const loadedError = mirrorPeerLoadError || drClustersLoadError; - const clusterNames = React.useMemo( - () => state.selectedClusters.map(getName), - [state.selectedClusters] + const clusterNames = state.selectedClusters.map(getName); + const shouldRunValidation = shouldRunPrePairValidation( + state.selectedClusters.length, + state.isClusterSelectionValid, + state.replicationBackend === BackendType.DataFoundation + ); + // Cluster pair validation runs only after the user leaves the clusters page. + const validationActive = + shouldRunValidation && step !== CreateDRPolicyWizardSteps.Clusters; + const prePairValidation = usePrePairNetworkValidation( + clusterNames, + validationActive ); + const prePairValidationPassed = + !validationActive || prePairValidation.canProceed; const acmDocVersion = useDocVersion({ defaultDocVersion: ACM_DEFAULT_DOC_VERSION, @@ -328,189 +278,94 @@ export const CreateDRPolicyForm: React.FC = ({ }); const acmDoc = acmDocHome(acmDocVersion); + const submarinerDoc = acmSubmarinerDoc(acmDocVersion); + + const allDRClustersExist = selectedDRClusters.length === MAX_ALLOWED_CLUSTERS; if (!loaded || loadedError) { return ; } + const stepNames = CreateDRPolicyStepNames(t); + const stepValidity: Record = { + [CreateDRPolicyWizardSteps.Clusters]: validateClusterInputs( + state, + allDRClustersExist + ), + [CreateDRPolicyWizardSteps.Configure]: prePairValidation.canProceed, + [CreateDRPolicyWizardSteps.Policy]: validatePolicyInputs(state), + [CreateDRPolicyWizardSteps.Review]: + validateDRPolicyInputs(state, allDRClustersExist) && + prePairValidationPassed, + }; + return ( -
- - setPolicyName(strVal)} - isRequired + + setStep(currentStep.id as CreateDRPolicyWizardSteps) + } + footer={ + - - - - - - {t( - 'Enables mirroring/replication between two selected clusters, ensuring failover or relocation between the two clusters in the event of an outage or planned maintenance.' - )} - - - - + + 0} + requiredODFVersion={odfMCOVersion} + preSelectedClusters={preSelectedClusters} + acmDoc={acmDoc} + mirrorPeers={mirrorPeers} + clusterNames={clusterNames} + selectedDRClusters={selectedDRClusters} + errorMessage={s3ErrorMessage} /> - - - - - - - Note: If your managed cluster does not appear here, confirm it - is successfully imported and refer to the{' '} - RHACM documentation{' '} - for more details. - - - - - - {state.selectedClusters.length === 2 && ( - <> - - - - {state.isClusterSelectionValid && ( - <> - {!state.selectedClustersHaveODF && - state.selectedClusters.some( - (c) => c?.odfInfo?.storageClusterCount > 0 - ) && ( - - - - - {t( - 'All disaster recovery prerequisites are met for both clusters. Multiple storage backends are available on both of the selected clusters.' - )} - - - - - - )} - {!state.selectedClustersHaveODF && - state.replicationBackend === BackendType.ThirdParty && ( - <> - - - - - - {t( - 'Provide S3 bucket connection details for each managed cluster. If a S3 bucket is not already configured for cluster, create one and then continue.' - )} - - - - - - - )} - - + + + + + + + + + {shouldRunValidation && ( + + - {state.replicationBackend === BackendType.DataFoundation && - state.replicationType === ReplicationType.ASYNC && ( - - - - )} - {errorMessage && ( - - - {errorMessage} - - - )} - + )} - - )} - - - - - + + + ); }; - -type AdvancedSettingsProps = { - enableRBDImageFlatten: boolean; - dispatch: React.Dispatch; -}; diff --git a/packages/mco/components/create-dr-policy/clusters-step.tsx b/packages/mco/components/create-dr-policy/clusters-step.tsx new file mode 100644 index 000000000..2e753109f --- /dev/null +++ b/packages/mco/components/create-dr-policy/clusters-step.tsx @@ -0,0 +1,169 @@ +import * as React from 'react'; +import { DOC_VERSION, tpsDoc } from '@odf/shared'; +import { getName } from '@odf/shared/selectors'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { ExternalLink } from '@odf/shared/utils'; +import { Trans } from 'react-i18next'; +import { + Alert, + AlertVariant, + Form, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, +} from '@patternfly/react-core'; +import { BackendType, MAX_ALLOWED_CLUSTERS } from '../../constants'; +import { DRClusterKind, MirrorPeerKind } from '../../types'; +import { ClusterS3BucketDetailsForm } from './add-s3-bucket-details/s3-bucket-details-form'; +import { SelectClusterList } from './select-cluster-list'; +import { SelectReplicationBackend } from './select-replication-backend/select-replication-backend'; +import { SelectedClusterValidation } from './selected-cluster-validator'; +import ThirdPartyStorageWarning from './third-party-storage-alert'; +import { DRPolicyAction, DRPolicyState } from './utils/reducer'; + +export const ClustersStep: React.FC = ({ + state, + dispatch, + requiredODFVersion, + preSelectedClusters, + acmDoc, + mirrorPeers, + clusterNames, + selectedDRClusters, + errorMessage, +}) => { + const { t } = useCustomTranslation(); + + return ( +
+ + + + + {t( + 'Enables mirroring/replication between two selected clusters, ensuring failover or relocation between the two clusters in the event of an outage or planned maintenance.' + )} + + + + 0} + /> + + + + + + + Note: If your managed cluster does not appear here, confirm it + is successfully imported and refer to the{' '} + RHACM documentation{' '} + for more details. + + + + + + {state.selectedClusters.length === MAX_ALLOWED_CLUSTERS && ( + <> + + + + {state.isClusterSelectionValid && ( + <> + {!state.selectedClustersHaveODF && + state.selectedClusters.some( + (cluster) => cluster?.odfInfo?.storageClusterCount > 0 + ) && ( + + + + + {t( + 'All disaster recovery prerequisites are met for both clusters. Multiple storage backends are available on both of the selected clusters.' + )} + + + + + + )} + {!state.selectedClustersHaveODF && + state.replicationBackend === BackendType.ThirdParty && ( + <> + + + + + + {t( + 'Provide S3 bucket connection details for each managed cluster. If a S3 bucket is not already configured for cluster, create one and then continue.' + )} + + + + + + + )} + {!!errorMessage && ( + + + {errorMessage} + + + )} + + )} + + )} + + ); +}; + +type ClustersStepProps = { + state: DRPolicyState; + dispatch: React.Dispatch; + requiredODFVersion: string; + preSelectedClusters: string[]; + acmDoc: string; + mirrorPeers: MirrorPeerKind[]; + clusterNames: string[]; + selectedDRClusters: DRClusterKind[]; + errorMessage: string; +}; diff --git a/packages/mco/components/create-dr-policy/configure-cluster-pair-step.tsx b/packages/mco/components/create-dr-policy/configure-cluster-pair-step.tsx new file mode 100644 index 000000000..01d7e4fc5 --- /dev/null +++ b/packages/mco/components/create-dr-policy/configure-cluster-pair-step.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { PrePairNetworkValidationState } from '@odf/mco/hooks'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { Content, ContentVariants, Title } from '@patternfly/react-core'; +import { PrePairNetworkValidation } from './pre-pair-network-validation'; + +type ConfigureClusterPairStepProps = { + clusterNames: string[]; + validation: PrePairNetworkValidationState; + docHref?: string; +}; + +export const ConfigureClusterPairStep: React.FC< + ConfigureClusterPairStepProps +> = ({ clusterNames, validation, docHref }) => { + const { t } = useCustomTranslation(); + + return ( +
+ + {t('Configure cluster pair')} + + + + {t( + 'The first time a policy is created between two clusters, the clusters must be paired together.' + )} + + + +
+ ); +}; diff --git a/packages/mco/components/create-dr-policy/create-dr-policy.scss b/packages/mco/components/create-dr-policy/create-dr-policy.scss index 5d2303ddb..73bc5968a 100644 --- a/packages/mco/components/create-dr-policy/create-dr-policy.scss +++ b/packages/mco/components/create-dr-policy/create-dr-policy.scss @@ -3,10 +3,6 @@ width: 90%; } -.mco-create-data-policy__action-group { - margin-bottom: var(--pf-t--global--spacer--md); -} - .mco-create-data-policy__description { margin-bottom: var(--pf-t--global--spacer--sm); } @@ -29,3 +25,7 @@ width: 67%; } +.mco-create-data-policy__wizard--height { + max-height: 74vh; +} + diff --git a/packages/mco/components/create-dr-policy/create-dr-policy.spec.tsx b/packages/mco/components/create-dr-policy/create-dr-policy.spec.tsx index 4cfe74c0b..f25da75eb 100644 --- a/packages/mco/components/create-dr-policy/create-dr-policy.spec.tsx +++ b/packages/mco/components/create-dr-policy/create-dr-policy.spec.tsx @@ -243,13 +243,8 @@ describe.skip('Test drpolicy list page', () => { ) ).toBeInTheDocument(); - // Create button should be disabled - expect(screen.getByTestId('create-button')).toBeDisabled(); - - // Enter policy name - expect(screen.getByText('Policy name')).toBeInTheDocument(); - - await userEvent.type(screen.getByTestId('policy-name'), 'policy-1'); + // Next button should be disabled + expect(screen.getByTestId('next-button')).toBeDisabled(); // Managed cluster pairing expect(screen.getByText('Connect clusters')).toBeInTheDocument(); @@ -268,14 +263,29 @@ describe.skip('Test drpolicy list page', () => { expect(screen.getAllByText('east-1')).toHaveLength(2); expect(screen.getAllByText('west-1')).toHaveLength(2); expect(screen.getAllByText('ocs-storagecluster').length === 2).toBeTruthy(); - expect(screen.getByText('Asynchronous')).toBeInTheDocument(); expect( screen.getByText( 'All disaster recovery prerequisites met for both clusters.' ) ).toBeInTheDocument(); - // Create button should be enabled + // Next opens cluster pair configuration + expect(screen.getByTestId('next-button')).toBeEnabled(); + await userEvent.click(screen.getByTestId('next-button')); + expect(screen.getByText('Configure cluster pair')).toBeInTheDocument(); + expect(screen.getByTestId('next-button')).toBeEnabled(); + await userEvent.click(screen.getByTestId('next-button')); + + // Policy step: enter policy name, replication defaults to async + expect(screen.getByText('Policy name')).toBeInTheDocument(); + expect(screen.getByTestId('next-button')).toBeDisabled(); + await userEvent.type(screen.getByTestId('policy-name'), 'policy-1'); + expect(screen.getByText('Asynchronous')).toBeInTheDocument(); + expect(screen.getByTestId('next-button')).toBeEnabled(); + await userEvent.click(screen.getByTestId('next-button')); + + // Review and create + expect(screen.getByText('Review and create')).toBeInTheDocument(); expect(screen.getByTestId('create-button')).toBeEnabled(); // Create DRPolicy @@ -295,12 +305,8 @@ describe.skip('Test drpolicy list page', () => { test('Metro-DR policy creation happy path testing', async () => { render(); testcase = 2; - // Create button should be disabled - expect(screen.getByTestId('create-button')).toBeDisabled(); - - // Enter policy name - expect(screen.getByText('Policy name')).toBeInTheDocument(); - await userEvent.type(screen.getByTestId('policy-name'), 'policy-1'); + // Next button should be disabled + expect(screen.getByTestId('next-button')).toBeDisabled(); // Managed cluster pairing await userEvent.click(screen.getByLabelText('Select row 0')); @@ -312,17 +318,26 @@ describe.skip('Test drpolicy list page', () => { expect(screen.getAllByText('east-1')).toHaveLength(2); expect(screen.getAllByText('east-2')).toHaveLength(2); expect(screen.getAllByText('ocs-storagecluster')).toHaveLength(2); - expect(screen.getByText('Synchronous')).toBeInTheDocument(); expect( screen.getByText( 'All disaster recovery prerequisites met for both clusters.' ) ).toBeInTheDocument(); - // Create button should be enabled - expect(screen.getByTestId('create-button')).toBeEnabled(); + // Next through cluster pair configuration + expect(screen.getByTestId('next-button')).toBeEnabled(); + await userEvent.click(screen.getByTestId('next-button')); + expect(screen.getByText('Configure cluster pair')).toBeInTheDocument(); + await userEvent.click(screen.getByTestId('next-button')); + + // Policy step: enter policy name, replication defaults to sync + expect(screen.getByText('Policy name')).toBeInTheDocument(); + await userEvent.type(screen.getByTestId('policy-name'), 'policy-1'); + expect(screen.getByText('Synchronous')).toBeInTheDocument(); + await userEvent.click(screen.getByTestId('next-button')); // Create DRPolicy + expect(screen.getByTestId('create-button')).toBeEnabled(); await userEvent.click(screen.getByTestId('create-button')); // Validate kube object creation @@ -362,8 +377,8 @@ describe.skip('Test drpolicy list page', () => { 'We could not retrieve any information about the managed cluster {{clusterName}}' ) ).toBeInTheDocument(); - // Create button should be disabled - expect(screen.getByTestId('create-button')).toBeDisabled(); + // Next button should be disabled + expect(screen.getByTestId('next-button')).toBeDisabled(); }); test('More than two cluster selection', async () => { diff --git a/packages/mco/components/create-dr-policy/footer.tsx b/packages/mco/components/create-dr-policy/footer.tsx new file mode 100644 index 000000000..0d7df5f22 --- /dev/null +++ b/packages/mco/components/create-dr-policy/footer.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { CreateDRPolicyWizardSteps } from '@odf/mco/constants'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { + Alert, + AlertVariant, + Button, + ButtonVariant, + useWizardContext, + WizardFooterWrapper, +} from '@patternfly/react-core'; + +export const CreateDRPolicyWizardFooter: React.FC< + CreateDRPolicyWizardFooterProps +> = ({ stepValidity, isLoading, errorMessage, onCreate, onCancel }) => { + const { t } = useCustomTranslation(); + const { activeStep, goToNextStep, goToPrevStep } = useWizardContext(); + + const isClustersStep = activeStep.id === CreateDRPolicyWizardSteps.Clusters; + const isReviewStep = activeStep.id === CreateDRPolicyWizardSteps.Review; + + return ( + <> + {!!errorMessage && isReviewStep && ( + + {errorMessage} + + )} + + + + + + + ); +}; + +type CreateDRPolicyWizardFooterProps = { + stepValidity: Record; + isLoading: boolean; + errorMessage: string; + onCreate: () => void; + onCancel: () => void; +}; diff --git a/packages/mco/components/create-dr-policy/policy-step.tsx b/packages/mco/components/create-dr-policy/policy-step.tsx new file mode 100644 index 000000000..6f75b51b7 --- /dev/null +++ b/packages/mco/components/create-dr-policy/policy-step.tsx @@ -0,0 +1,126 @@ +import * as React from 'react'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { Trans } from 'react-i18next'; +import { + Alert, + AlertVariant, + Checkbox, + Content, + ContentVariants, + ExpandableSection, + Form, + FormGroup, + TextInput, + Title, +} from '@patternfly/react-core'; +import { BackendType, ReplicationType } from '../../constants'; +import { SelectReplicationType } from './select-replication-type'; +import { + DRPolicyAction, + DRPolicyActionType, + DRPolicyState, +} from './utils/reducer'; + +const AdvancedSettings: React.FC = ({ + enableRBDImageFlatten, + dispatch, +}) => { + const { t } = useCustomTranslation(); + + return ( + + + dispatch({ + type: DRPolicyActionType.SET_RBD_IMAGE_FLATTEN, + payload: checked, + }) + } + id="flat-image-checkbox" + name="flat-image-checkbox" + /> + + Before choosing this option, read the section + + Creating Disaster Recovery Policy on Hub cluster chapter of + Regional-DR solution guide + + to understand the impact and limitations of this feature. + + } + variant={AlertVariant.warning} + isInline + /> + + ); +}; + +export const PolicyStep: React.FC = ({ state, dispatch }) => { + const { t } = useCustomTranslation(); + + return ( +
+ + {t('Policy')} + + + + {t('Select a replication policy type and interval.')} + + + + + dispatch({ + type: DRPolicyActionType.SET_POLICY_NAME, + payload: policyName, + }) + } + isRequired + /> + + + {state.replicationBackend === BackendType.DataFoundation && + state.replicationType === ReplicationType.ASYNC && ( + + + + )} + + ); +}; + +type AdvancedSettingsProps = { + enableRBDImageFlatten: boolean; + dispatch: React.Dispatch; +}; + +type PolicyStepProps = { + state: DRPolicyState; + dispatch: React.Dispatch; +}; diff --git a/packages/mco/components/create-dr-policy/pre-pair-network-validation.tsx b/packages/mco/components/create-dr-policy/pre-pair-network-validation.tsx new file mode 100644 index 000000000..833a5e902 --- /dev/null +++ b/packages/mco/components/create-dr-policy/pre-pair-network-validation.tsx @@ -0,0 +1,258 @@ +import * as React from 'react'; +import { + GlobalnetCheckStatus, + GlobalnetRequirement, + SubmarinerClusterHealth, +} from '@odf/mco/constants'; +import { PrePairNetworkValidationState } from '@odf/mco/hooks'; +import { + isMixedSubmarinerInstall, + isMixedSubmarinerInstallMethod, +} from '@odf/mco/utils/submariner-health'; +import { StatusBox } from '@odf/shared/generic/status-box'; +import { + GreenCheckCircleIcon, + RedExclamationCircleIcon, + YellowExclamationTriangleIcon, +} from '@odf/shared/status'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { ViewDocumentation } from '@odf/shared/utils'; +import { StatusIconAndText } from '@openshift-console/dynamic-plugin-sdk'; +import { TFunction } from 'react-i18next'; +import { + Content, + ContentVariants, + Spinner, + Title, +} from '@patternfly/react-core'; + +type StatusLine = { + icon: React.ReactElement; + title: string; + description?: string; + showDocLink?: boolean; +}; + +const getSubmarinerStatusLine = ( + health: SubmarinerClusterHealth, + upstreamMessage: string | undefined, + failureMessage: string | undefined, + t: TFunction +): StatusLine => { + switch (health) { + case SubmarinerClusterHealth.Checking: + return { + icon: , + title: t('Checking cluster network connectivity...'), + }; + case SubmarinerClusterHealth.Progressing: + return { + icon: , + title: t('Cluster network configuration in progress'), + }; + case SubmarinerClusterHealth.Healthy: + return { + icon: , + title: t('Cluster network connected'), + }; + case SubmarinerClusterHealth.UpstreamDetected: + return { + icon: , + title: t('Cluster network - Skipped'), + description: upstreamMessage, + }; + case SubmarinerClusterHealth.Degraded: + return { + icon: , + title: t('Degraded - Cluster unhealthy'), + description: failureMessage, + showDocLink: true, + }; + case SubmarinerClusterHealth.NotInstalled: + default: + return { + icon: , + title: t('Cluster network - Skipped'), + description: t( + 'Deployment is not using submariner. Selected cluster pairs lack submariner addon' + ), + }; + } +}; + +const getGlobalnetStatusLine = ( + status: GlobalnetCheckStatus, + requirement: GlobalnetRequirement, + t: TFunction +): StatusLine => { + if ( + status === GlobalnetCheckStatus.Checking || + requirement === GlobalnetRequirement.Checking + ) { + return { + icon: , + title: t('Enabling...'), + }; + } + + if ( + status === GlobalnetCheckStatus.NotFound && + requirement === GlobalnetRequirement.Required + ) { + return { + icon: , + title: t('Not enabled'), + description: t( + 'Globalnet is required as CIDRs overlap. Broker is missing' + ), + showDocLink: true, + }; + } + + switch (status) { + case GlobalnetCheckStatus.Enabled: + return { + icon: , + title: t('Enabled'), + description: + requirement === GlobalnetRequirement.Required + ? t( + 'Globalnet is on. Cluster networks have overlapping Pod or Service CIDR.' + ) + : t('Globalnet is on. Cluster networks do not overlap'), + }; + case GlobalnetCheckStatus.Disabled: + return { + icon: , + title: t('Not enabled'), + description: t('Globalnet is off'), + showDocLink: true, + }; + case GlobalnetCheckStatus.NotFound: + default: + return { + icon: , + title: t('Not enabled'), + description: t('Submariner broker configuration not found'), + showDocLink: true, + }; + } +}; + +const StatusSection: React.FC = ({ + heading, + line, + docHref, +}) => ( + <> + + {heading} + + + {(line.description || (line.showDocLink && docHref)) && ( + + {line.description} + {line.showDocLink && !!docHref && ( + + )} + + )} + +); + +export const PrePairNetworkValidation: React.FC< + PrePairNetworkValidationProps +> = ({ clusterNames, validation, docHref }) => { + const { t } = useCustomTranslation(); + + const { + submarinerOverallHealth, + globalnetStatus, + globalnetRequirement, + clusterStatuses, + } = validation; + + const mixedInstallMessage = isMixedSubmarinerInstall(clusterStatuses) + ? t('Submariner is not installed on one or both selected cluster pairs') + : undefined; + let upstreamDetectedMessage: string | undefined; + if (submarinerOverallHealth === SubmarinerClusterHealth.UpstreamDetected) { + upstreamDetectedMessage = isMixedSubmarinerInstallMethod(clusterStatuses) + ? t( + 'Upstream Submariner detected. Advanced validation is only available for ACM-managed Submariner. The selected clusters use different Submariner installation methods.' + ) + : t( + 'Upstream Submariner detected. Advanced validation is only available for ACM-managed Submariner.' + ); + } + + const submariner = getSubmarinerStatusLine( + submarinerOverallHealth, + upstreamDetectedMessage, + mixedInstallMessage, + t + ); + const isSubmarinerDegraded = + submarinerOverallHealth === SubmarinerClusterHealth.Degraded; + const showGlobalnet = globalnetStatus !== GlobalnetCheckStatus.Skipped; + const globalnet = getGlobalnetStatusLine( + globalnetStatus, + globalnetRequirement, + t + ); + + return ( + } + title={t('Checking cluster network connectivity...')} + /> + } + > + + {isSubmarinerDegraded && + clusterStatuses + .filter( + (status) => status.health === SubmarinerClusterHealth.Degraded + ) + .map((status) => ( + + {status.clusterName} + {status.message ? `: ${status.message}` : ''} + + ))} + {showGlobalnet && ( +
+ +
+ )} +
+ ); +}; + +type StatusSectionProps = { + heading: string; + line: StatusLine; + docHref?: string; +}; + +type PrePairNetworkValidationProps = { + clusterNames: string[]; + validation: PrePairNetworkValidationState; + docHref?: string; +}; diff --git a/packages/mco/components/create-dr-policy/review-dr-policy-step.tsx b/packages/mco/components/create-dr-policy/review-dr-policy-step.tsx new file mode 100644 index 000000000..6188bc9ce --- /dev/null +++ b/packages/mco/components/create-dr-policy/review-dr-policy-step.tsx @@ -0,0 +1,96 @@ +import * as React from 'react'; +import { + BackendType, + REPLICATION_DISPLAY_TEXT, + ReplicationType, + SYNC_SCHEDULE_DISPLAY_TEXT, +} from '@odf/mco/constants'; +import { parseSyncInterval } from '@odf/mco/utils'; +import { + ReviewAndCreateStep, + ReviewAndCreationGroup, + ReviewAndCreationItem, +} from '@odf/shared/review-and-create-step'; +import { getName } from '@odf/shared/selectors'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { Content, ContentVariants, Title } from '@patternfly/react-core'; +import { DRPolicyState } from './utils/reducer'; + +type ReviewDRPolicyStepProps = { + state: DRPolicyState; + children?: React.ReactNode; +}; + +export const ReviewDRPolicyStep: React.FC = ({ + state, + children, +}) => { + const { t } = useCustomTranslation(); + const clusterNames = state.selectedClusters.map(getName).join(', '); + const replicationLabel = state.replicationType + ? REPLICATION_DISPLAY_TEXT(t)[state.replicationType] + : ''; + const [unitVal, interval] = parseSyncInterval(state.syncIntervalTime); + const syncSchedule = + state.replicationType === ReplicationType.ASYNC + ? `${interval} ${SYNC_SCHEDULE_DISPLAY_TEXT(t)[unitVal]}` + : t('N/A'); + + return ( +
+ + {t('Review and create')} + + + + {t( + 'Confirm the disaster recovery policy details before creating the policy.' + )} + + + + + + {state.policyName} + + + + + {clusterNames} + + + {children} + + + {replicationLabel} + + {state.replicationType === ReplicationType.ASYNC && ( + + {syncSchedule} + + )} + {state.replicationBackend === BackendType.DataFoundation && + state.replicationType === ReplicationType.ASYNC && ( + + {state.enableRBDImageFlatten + ? t('RBD image flatten enabled') + : t('RBD image flatten disabled')} + + )} + + {state.replicationBackend === BackendType.ThirdParty && ( + + + {state.cluster1S3Details.s3ProfileName} + + + {state.useSameS3Connection + ? state.cluster1S3Details.s3ProfileName + : state.cluster2S3Details.s3ProfileName} + + + )} + +
+ ); +}; diff --git a/packages/mco/constants/disaster-recovery.ts b/packages/mco/constants/disaster-recovery.ts index c8399b743..3cd29e06b 100644 --- a/packages/mco/constants/disaster-recovery.ts +++ b/packages/mco/constants/disaster-recovery.ts @@ -113,6 +113,20 @@ export const EnrollDiscoveredApplicationStepNames = (t: TFunction) => ({ [EnrollDiscoveredApplicationSteps.Review]: t('Review'), }); +// Create DRPolicy wizard steps +export enum CreateDRPolicyWizardSteps { + Clusters = 'clusters', + Configure = 'configure', + Policy = 'policy', + Review = 'review', +} +export const CreateDRPolicyStepNames = (t: TFunction) => ({ + [CreateDRPolicyWizardSteps.Clusters]: t('Clusters'), + [CreateDRPolicyWizardSteps.Configure]: t('Configure'), + [CreateDRPolicyWizardSteps.Policy]: t('Policy'), + [CreateDRPolicyWizardSteps.Review]: t('Review'), +}); + export const MCV_NAME_TEMPLATE = 'odf-multicluster-mcv-'; export const MCO_CREATED_BY_LABEL_KEY = diff --git a/packages/mco/constants/doc.tsx b/packages/mco/constants/doc.tsx index d2013b717..9c1adb8ce 100644 --- a/packages/mco/constants/doc.tsx +++ b/packages/mco/constants/doc.tsx @@ -8,6 +8,8 @@ export const acmDocHome = (acmDocVersion) => `https://access.redhat.com/documentation/en-us/red_hat_advanced_cluster_management_for_kubernetes/${acmDocVersion}`; export const acmDocBasePath = (acmDocVersion) => `${acmDocHome(acmDocVersion)}/html-single`; +export const acmSubmarinerDoc = (acmDocVersion) => + `${acmDocBasePath(acmDocVersion)}/networking/index#submariner`; export const docLinks = (mcoDocVersion, acmDocVersion) => ({ APPLY_POLICY: odfDRDocApplyPolicy(mcoDocVersion), diff --git a/packages/mco/constants/index.ts b/packages/mco/constants/index.ts index 9d1e0e012..18f464cfe 100644 --- a/packages/mco/constants/index.ts +++ b/packages/mco/constants/index.ts @@ -4,4 +4,4 @@ export * from './common'; export * from './acm'; export * from './doc'; export * from './dashboard'; -export * from './url-paths'; +export * from './submariner'; diff --git a/packages/mco/constants/submariner.ts b/packages/mco/constants/submariner.ts new file mode 100644 index 000000000..3ee833669 --- /dev/null +++ b/packages/mco/constants/submariner.ts @@ -0,0 +1,56 @@ +export const SUBMARINER_ADDON_NAME = 'submariner'; +export const SUBMARINER_BROKER_NAME = 'submariner-broker'; +export const SUBMARINER_BROKER_NAMESPACE = 'submariner-k8s-broker'; +export const SUBMARINER_OPERATOR_NAMESPACE = 'submariner-operator'; +// Spoke Submariner CR is always named 'submariner' regardless of install method. +export const SUBMARINER_RESOURCE_NAME = 'submariner'; + +// Watch resources use references; ManagedClusterView requires separate GVK fields. +export const SUBMARINER_ADDON_KIND = + 'addon.open-cluster-management.io~v1alpha1~ManagedClusterAddOn'; +export const SUBMARINER_BROKER_KIND = 'submariner.io~v1alpha1~Broker'; +export const SUBMARINER_CLUSTER_KIND = 'submariner.io~v1~Cluster'; + +export const SUBMARINER_GVK = { + group: 'submariner.io', + version: 'v1alpha1', + kind: 'Submariner', +} as const; + +export const SUBMARINER_CONDITION_TYPES = { + AVAILABLE: 'Available', + CONNECTION_DEGRADED: 'SubmarinerConnectionDegraded', + ROUTE_AGENT_CONNECTION_DEGRADED: 'RouteAgentConnectionDegraded', + AGENT_DEGRADED: 'SubmarinerAgentDegraded', + GATEWAY_NODES_LABELED: 'SubmarinerGatewayNodesLabeled', +} as const; + +export const NETWORK_CLUSTER_CLAIM_NAMES = [ + 'network.openshift.io', + 'network.cluster.open-cluster-management.io', +] as const; + +export enum SubmarinerClusterHealth { + NotInstalled = 'notInstalled', + UpstreamDetected = 'upstreamDetected', + Checking = 'checking', + Healthy = 'healthy', + Progressing = 'progressing', + Degraded = 'degraded', +} + +export enum GlobalnetCheckStatus { + Skipped = 'skipped', + Checking = 'checking', + Enabled = 'enabled', + Disabled = 'disabled', + NotFound = 'notFound', +} + +export enum GlobalnetRequirement { + Skipped = 'skipped', + Checking = 'checking', + Unknown = 'unknown', + Required = 'required', + NotRequired = 'notRequired', +} diff --git a/packages/mco/hooks/index.ts b/packages/mco/hooks/index.ts index 33c35b7f9..a13d6d116 100644 --- a/packages/mco/hooks/index.ts +++ b/packages/mco/hooks/index.ts @@ -8,3 +8,4 @@ export * from './useProtectedAppsByCluster'; export * from './useDRPoliciesByClusterPair'; export * from './useActiveDROperations'; export * from './use-ramen-config'; +export * from './use-pre-pair-network-validation'; diff --git a/packages/mco/hooks/mco-resources.ts b/packages/mco/hooks/mco-resources.ts index 5e71876ee..04b3dd24d 100644 --- a/packages/mco/hooks/mco-resources.ts +++ b/packages/mco/hooks/mco-resources.ts @@ -15,7 +15,13 @@ import { } from '@odf/shared/models'; import { referenceForModel } from '@odf/shared/utils'; import { Selector } from '@openshift-console/dynamic-plugin-sdk'; -import { HUB_CLUSTER_NAME } from '../constants'; +import { + HUB_CLUSTER_NAME, + SUBMARINER_ADDON_KIND, + SUBMARINER_BROKER_KIND, + SUBMARINER_BROKER_NAMESPACE, + SUBMARINER_CLUSTER_KIND, +} from '../constants'; export const getDRClusterResourceObj = (props?: ClusterScopeObjectType) => ({ cluster: HUB_CLUSTER_NAME, @@ -148,6 +154,33 @@ export const getApplicationResourceObj = (props?: NamespacedObjectType) => ({ optional: true, }); +export const getSubmarinerAddonListResourceObj = (clusterName: string) => ({ + cluster: HUB_CLUSTER_NAME, + kind: SUBMARINER_ADDON_KIND, + isList: true, + namespace: clusterName, + namespaced: true, + optional: true, +}); + +export const getSubmarinerBrokerListResourceObj = () => ({ + cluster: HUB_CLUSTER_NAME, + kind: SUBMARINER_BROKER_KIND, + isList: true, + namespace: SUBMARINER_BROKER_NAMESPACE, + namespaced: true, + optional: true, +}); + +export const getSubmarinerClusterListResourceObj = () => ({ + cluster: HUB_CLUSTER_NAME, + kind: SUBMARINER_CLUSTER_KIND, + isList: true, + namespace: SUBMARINER_BROKER_NAMESPACE, + namespaced: true, + optional: true, +}); + type ClusterScopeObjectType = { name?: string; selector?: Selector; diff --git a/packages/mco/hooks/use-pre-pair-network-validation.ts b/packages/mco/hooks/use-pre-pair-network-validation.ts new file mode 100644 index 000000000..01f8d70cb --- /dev/null +++ b/packages/mco/hooks/use-pre-pair-network-validation.ts @@ -0,0 +1,279 @@ +import * as React from 'react'; +import { + GlobalnetCheckStatus, + GlobalnetRequirement, + MAX_ALLOWED_CLUSTERS, + SUBMARINER_ADDON_NAME, + SUBMARINER_GVK, + SUBMARINER_OPERATOR_NAMESPACE, + SUBMARINER_RESOURCE_NAME, + SubmarinerClusterHealth, +} from '@odf/mco/constants'; +import { + ACMManagedClusterKind, + ClusterSubmarinerStatus, + SubmarinerAddOnKind, + SubmarinerBrokerKind, + SubmarinerClusterKind, +} from '@odf/mco/types'; +import { fireManagedClusterView } from '@odf/mco/utils/managed-cluster-view'; +import { + evaluateGlobalnetPrePair, + evaluateSubmarinerPrePair, +} from '@odf/mco/utils/submariner-health'; +import { getName } from '@odf/shared/selectors'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { + getValidWatchK8sResourceObj, + isNotFoundError, +} from '@odf/shared/utils'; +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { TFunction } from 'react-i18next'; +import { + getManagedClusterResourceObj, + getSubmarinerAddonListResourceObj, + getSubmarinerBrokerListResourceObj, + getSubmarinerClusterListResourceObj, +} from './mco-resources'; + +export type PrePairNetworkValidationState = { + loaded: boolean; + loadError: unknown; + canProceed: boolean; + submarinerOverallHealth: SubmarinerClusterHealth; + globalnetStatus: GlobalnetCheckStatus; + globalnetRequirement: GlobalnetRequirement; + clusterStatuses: ClusterSubmarinerStatus[]; +}; + +const idleState: PrePairNetworkValidationState = { + loaded: true, + loadError: null, + canProceed: true, + submarinerOverallHealth: SubmarinerClusterHealth.NotInstalled, + globalnetStatus: GlobalnetCheckStatus.Skipped, + globalnetRequirement: GlobalnetRequirement.Skipped, + clusterStatuses: [], +}; + +const isAddonAbsent = ( + loaded: boolean, + addon: SubmarinerAddOnKind | undefined, + loadError: unknown +): boolean => loaded && !addon && (!loadError || isNotFoundError(loadError)); + +type UpstreamDetectionResult = { + detected: boolean; + pending: boolean; +}; + +const useUpstreamSubmarinerDetection = ( + clusterName: string | undefined, + enabled: boolean, + t: TFunction +): UpstreamDetectionResult => { + const [result, setResult] = React.useState({ + detected: false, + pending: false, + }); + + React.useEffect(() => { + if (!enabled || !clusterName) { + setResult({ detected: false, pending: false }); + return; + } + + let cancelled = false; + setResult({ detected: false, pending: true }); + + fireManagedClusterView( + SUBMARINER_RESOURCE_NAME, + SUBMARINER_OPERATOR_NAMESPACE, + SUBMARINER_GVK.kind, + SUBMARINER_GVK.version, + SUBMARINER_GVK.group, + clusterName, + t + ) + .then((response) => { + if (!cancelled) { + setResult({ detected: !!response.result, pending: false }); + } + }) + .catch(() => { + if (!cancelled) { + setResult({ detected: false, pending: false }); + } + }); + + return () => { + cancelled = true; + }; + }, [clusterName, enabled, t]); + + return result; +}; + +export const usePrePairNetworkValidation = ( + clusterNames: string[], + enabled: boolean +): PrePairNetworkValidationState => { + const { t } = useCustomTranslation(); + const clusterA = clusterNames[0]; + const clusterB = clusterNames[1]; + const shouldWatch = enabled && clusterNames.length === MAX_ALLOWED_CLUSTERS; + + const [addonsA, loadedA, errorA] = useK8sWatchResource( + getValidWatchK8sResourceObj( + getSubmarinerAddonListResourceObj(clusterA || ''), + shouldWatch && !!clusterA + ) + ); + const [addonsB, loadedB, errorB] = useK8sWatchResource( + getValidWatchK8sResourceObj( + getSubmarinerAddonListResourceObj(clusterB || ''), + shouldWatch && !!clusterB + ) + ); + const addonA = addonsA?.find( + (addon) => getName(addon) === SUBMARINER_ADDON_NAME + ); + const addonB = addonsB?.find( + (addon) => getName(addon) === SUBMARINER_ADDON_NAME + ); + + const watchAcmGlobalnet = + shouldWatch && ((loadedA && !!addonA) || (loadedB && !!addonB)); + + const [brokers, brokersLoaded, brokersError] = useK8sWatchResource< + SubmarinerBrokerKind[] + >( + getValidWatchK8sResourceObj( + getSubmarinerBrokerListResourceObj(), + watchAcmGlobalnet + ) + ); + const [managedClusterA, managedClusterALoaded, managedClusterAError] = + useK8sWatchResource( + getValidWatchK8sResourceObj( + getManagedClusterResourceObj({ name: clusterA }), + watchAcmGlobalnet && !!clusterA + ) + ); + const [managedClusterB, managedClusterBLoaded, managedClusterBError] = + useK8sWatchResource( + getValidWatchK8sResourceObj( + getManagedClusterResourceObj({ name: clusterB }), + watchAcmGlobalnet && !!clusterB + ) + ); + const [ + submarinerClusters, + submarinerClustersLoaded, + submarinerClustersError, + ] = useK8sWatchResource( + getValidWatchK8sResourceObj( + getSubmarinerClusterListResourceObj(), + watchAcmGlobalnet + ) + ); + + const shouldDetectUpstreamA = + shouldWatch && !!clusterA && isAddonAbsent(loadedA, addonA, errorA); + const shouldDetectUpstreamB = + shouldWatch && !!clusterB && isAddonAbsent(loadedB, addonB, errorB); + const upstreamA = useUpstreamSubmarinerDetection( + clusterA, + shouldDetectUpstreamA, + t + ); + const upstreamB = useUpstreamSubmarinerDetection( + clusterB, + shouldDetectUpstreamB, + t + ); + + if (!shouldWatch) { + return idleState; + } + + // While the upstream lookup is in flight, keep the cluster in the + // "checking" state instead of briefly reporting "not installed". + const submarinerResult = evaluateSubmarinerPrePair([ + { + clusterName: clusterA, + addon: addonA, + loaded: loadedA && !upstreamA.pending, + loadError: errorA, + upstreamDetected: upstreamA.detected, + }, + { + clusterName: clusterB, + addon: addonB, + loaded: loadedB && !upstreamB.pending, + loadError: errorB, + upstreamDetected: upstreamB.detected, + }, + ]); + + const skipGlobalnetCheck = + submarinerResult.overallHealth === SubmarinerClusterHealth.NotInstalled || + submarinerResult.overallHealth === SubmarinerClusterHealth.UpstreamDetected; + + const globalnet = evaluateGlobalnetPrePair( + brokers, + watchAcmGlobalnet ? brokersLoaded : true, + watchAcmGlobalnet ? brokersError : null, + [ + { + clusterName: clusterA, + clusterClaims: managedClusterA?.status?.clusterClaims, + loaded: watchAcmGlobalnet ? managedClusterALoaded : true, + loadError: watchAcmGlobalnet ? managedClusterAError : null, + }, + { + clusterName: clusterB, + clusterClaims: managedClusterB?.status?.clusterClaims, + loaded: watchAcmGlobalnet ? managedClusterBLoaded : true, + loadError: watchAcmGlobalnet ? managedClusterBError : null, + }, + ], + submarinerClusters, + watchAcmGlobalnet ? submarinerClustersLoaded : true, + skipGlobalnetCheck + ); + + const loaded = + loadedA && + loadedB && + !upstreamA.pending && + !upstreamB.pending && + (!watchAcmGlobalnet || + (brokersLoaded && + managedClusterALoaded && + managedClusterBLoaded && + submarinerClustersLoaded)); + + const loadError = [ + errorA, + errorB, + ...(watchAcmGlobalnet + ? [ + brokersError, + managedClusterAError, + managedClusterBError, + submarinerClustersError, + ] + : []), + ].find((error) => error && !isNotFoundError(error)); + + return { + loaded, + loadError, + canProceed: !loadError && submarinerResult.canProceed, + submarinerOverallHealth: submarinerResult.overallHealth, + globalnetStatus: globalnet.status, + globalnetRequirement: globalnet.requirement, + clusterStatuses: submarinerResult.clusterStatuses, + }; +}; diff --git a/packages/mco/types/index.ts b/packages/mco/types/index.ts index 6cdd481f0..9f3ef1d1c 100644 --- a/packages/mco/types/index.ts +++ b/packages/mco/types/index.ts @@ -4,3 +4,4 @@ export * from './odf-mco'; export * from './argo-cd'; export * from './dashboard'; export * from './pav'; +export * from './submariner'; diff --git a/packages/mco/types/submariner.ts b/packages/mco/types/submariner.ts new file mode 100644 index 000000000..675c4d4cf --- /dev/null +++ b/packages/mco/types/submariner.ts @@ -0,0 +1,42 @@ +import { K8sResourceCondition } from '@odf/shared/types'; +import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; +import { SubmarinerClusterHealth } from '../constants/submariner'; +import { ClusterClaim } from './acm'; + +export type SubmarinerAddOnKind = K8sResourceCommon & { + status?: { + conditions?: K8sResourceCondition[]; + }; +}; + +export type SubmarinerBrokerKind = K8sResourceCommon & { + spec?: { + globalnetEnabled?: boolean; + }; +}; + +export type SubmarinerClusterKind = K8sResourceCommon & { + spec?: { + cluster_id?: string; + cluster_cidr?: string[]; + service_cidr?: string[]; + }; +}; + +export type ManagedClusterNetworkInfo = { + clusterName: string; + clusterClaims?: ClusterClaim[]; + loaded: boolean; + loadError?: unknown; +}; + +export type ClusterNetworkCidrs = { + clusterCidrs: string[]; + serviceCidrs: string[]; +}; + +export type ClusterSubmarinerStatus = { + clusterName: string; + health: SubmarinerClusterHealth; + message?: string; +}; diff --git a/packages/mco/utils/index.ts b/packages/mco/utils/index.ts index 71f622798..fca2000d8 100644 --- a/packages/mco/utils/index.ts +++ b/packages/mco/utils/index.ts @@ -2,3 +2,4 @@ export * from './disaster-recovery'; export * from './common'; export * from './acm-search-queries'; export * from './pav'; +export * from './submariner-health'; diff --git a/packages/mco/utils/submariner-health.spec.ts b/packages/mco/utils/submariner-health.spec.ts new file mode 100644 index 000000000..98cf97ad2 --- /dev/null +++ b/packages/mco/utils/submariner-health.spec.ts @@ -0,0 +1,523 @@ +import { + GlobalnetCheckStatus, + GlobalnetRequirement, + MAX_ALLOWED_CLUSTERS, + SUBMARINER_CONDITION_TYPES, + SubmarinerClusterHealth, +} from '@odf/mco/constants'; +import { SubmarinerAddOnKind } from '@odf/mco/types'; +import { K8sResourceConditionStatus } from '@odf/shared/types'; +import { doCidrsOverlap } from '@odf/shared/utils'; +import { + evaluateGlobalnetRequirement, + evaluateGlobalnetStatus, + evaluateSubmarinerPrePair, + extractCidrsFromNetworkClaimValue, + getSubmarinerClusterHealth, + isMixedSubmarinerInstall, + isMixedSubmarinerInstallMethod, + isGlobalnetRequiredButNotEnabled, + shouldRunPrePairValidation, +} from './submariner-health'; + +const healthyAddon: SubmarinerAddOnKind = { + apiVersion: 'addon.open-cluster-management.io/v1alpha1', + kind: 'ManagedClusterAddOn', + metadata: { name: 'submariner', namespace: 'local-cluster' }, + status: { + conditions: [ + { + type: SUBMARINER_CONDITION_TYPES.AVAILABLE, + status: K8sResourceConditionStatus.True, + message: 'Submariner agent is deployed and functioning normally.', + reason: 'SubmarinerAgentDeployed', + }, + { + type: 'SubMarinerAgentDegraded', + status: K8sResourceConditionStatus.False, + message: 'Submariner agent is not degraded.', + reason: 'SubmarinerAgentHealthy', + }, + { + type: 'SubMarinerConnectionDegraded', + status: K8sResourceConditionStatus.False, + message: + 'All Submariner tunnel connections to remote clusters are established.', + reason: 'SubMarinerConnectionsHealthy', + }, + ], + }, +}; + +const degradedAddon: SubmarinerAddOnKind = { + ...healthyAddon, + metadata: { name: 'submariner', namespace: 'dr1' }, + status: { + conditions: [ + { + type: SUBMARINER_CONDITION_TYPES.AVAILABLE, + status: K8sResourceConditionStatus.True, + }, + { + type: 'SubMarinerAgentDegraded', + status: K8sResourceConditionStatus.False, + }, + { + type: 'SubMarinerConnectionDegraded', + status: K8sResourceConditionStatus.True, + message: 'No connectivity to broker', + }, + ], + }, +}; + +describe('getSubmarinerClusterHealth', () => { + it('returns checking when addon is not loaded', () => { + expect(getSubmarinerClusterHealth(undefined, false, null)).toBe( + SubmarinerClusterHealth.Checking + ); + }); + + it('returns notInstalled when addon is not found', () => { + expect( + getSubmarinerClusterHealth(undefined, true, { + response: { status: 404 }, + message: 'NotFound', + }) + ).toBe(SubmarinerClusterHealth.NotInstalled); + }); + + it('returns degraded when addon watch fails with a non-404 error', () => { + expect( + getSubmarinerClusterHealth(undefined, true, { + response: { status: 403 }, + message: 'Forbidden', + }) + ).toBe(SubmarinerClusterHealth.Degraded); + }); + + it('returns healthy when condition types use mixed SubMariner casing', () => { + expect(getSubmarinerClusterHealth(healthyAddon, true, null)).toBe( + SubmarinerClusterHealth.Healthy + ); + }); + + it('returns progressing when Available exists but degraded conditions are not set yet', () => { + const installingAddon: SubmarinerAddOnKind = { + ...healthyAddon, + status: { + conditions: [ + { + type: SUBMARINER_CONDITION_TYPES.AVAILABLE, + status: K8sResourceConditionStatus.True, + }, + ], + }, + }; + expect(getSubmarinerClusterHealth(installingAddon, true, null)).toBe( + SubmarinerClusterHealth.Progressing + ); + }); + + it('returns degraded when connection is degraded', () => { + expect(getSubmarinerClusterHealth(degradedAddon, true, null)).toBe( + SubmarinerClusterHealth.Degraded + ); + }); + + it('returns progressing when required conditions are missing', () => { + const progressingAddon: SubmarinerAddOnKind = { + ...healthyAddon, + status: { + conditions: [ + { + type: SUBMARINER_CONDITION_TYPES.AVAILABLE, + status: K8sResourceConditionStatus.True, + }, + { + type: SUBMARINER_CONDITION_TYPES.CONNECTION_DEGRADED, + status: K8sResourceConditionStatus.False, + }, + ], + }, + }; + expect(getSubmarinerClusterHealth(progressingAddon, true, null)).toBe( + SubmarinerClusterHealth.Progressing + ); + }); +}); + +describe('evaluateSubmarinerPrePair', () => { + it('allows proceed when both clusters do not have Submariner addon', () => { + const result = evaluateSubmarinerPrePair([ + { + clusterName: 'a', + addon: undefined, + loaded: true, + loadError: { response: { status: 404 } }, + }, + { + clusterName: 'b', + addon: undefined, + loaded: true, + loadError: { response: { status: 404 } }, + }, + ]); + expect(result.overallHealth).toBe(SubmarinerClusterHealth.NotInstalled); + expect(result.canProceed).toBe(true); + }); + + it('blocks proceed when one cluster is degraded', () => { + const result = evaluateSubmarinerPrePair([ + { + clusterName: 'a', + addon: healthyAddon, + loaded: true, + loadError: null, + }, + { + clusterName: 'b', + addon: degradedAddon, + loaded: true, + loadError: null, + }, + ]); + expect(result.overallHealth).toBe(SubmarinerClusterHealth.Degraded); + expect(result.canProceed).toBe(false); + }); + + it('allows proceed when both clusters are healthy', () => { + const result = evaluateSubmarinerPrePair([ + { + clusterName: 'a', + addon: healthyAddon, + loaded: true, + loadError: null, + }, + { + clusterName: 'b', + addon: { + ...healthyAddon, + metadata: { name: 'submariner', namespace: 'b' }, + }, + loaded: true, + loadError: null, + }, + ]); + expect(result.overallHealth).toBe(SubmarinerClusterHealth.Healthy); + expect(result.canProceed).toBe(true); + }); + + it('blocks proceed when only one cluster has Submariner addon', () => { + const result = evaluateSubmarinerPrePair([ + { + clusterName: 'a', + addon: healthyAddon, + loaded: true, + loadError: null, + }, + { + clusterName: 'b', + addon: undefined, + loaded: true, + loadError: { response: { status: 404 } }, + }, + ]); + expect(result.canProceed).toBe(false); + }); + + it('allows proceed when both clusters have upstream Submariner detected', () => { + const result = evaluateSubmarinerPrePair([ + { + clusterName: 'a', + addon: undefined, + loaded: true, + loadError: { response: { status: 404 } }, + upstreamDetected: true, + }, + { + clusterName: 'b', + addon: undefined, + loaded: true, + loadError: { response: { status: 404 } }, + upstreamDetected: true, + }, + ]); + expect(result.overallHealth).toBe(SubmarinerClusterHealth.UpstreamDetected); + expect(result.canProceed).toBe(true); + }); + + it('allows proceed when ACM-managed and upstream Submariner are both detected', () => { + const result = evaluateSubmarinerPrePair([ + { + clusterName: 'a', + addon: healthyAddon, + loaded: true, + loadError: null, + }, + { + clusterName: 'b', + addon: undefined, + loaded: true, + loadError: { response: { status: 404 } }, + upstreamDetected: true, + }, + ]); + expect(result.overallHealth).toBe(SubmarinerClusterHealth.UpstreamDetected); + expect(result.canProceed).toBe(true); + }); + + it('blocks proceed when ACM-managed cluster is degraded even if peer is upstream', () => { + const result = evaluateSubmarinerPrePair([ + { + clusterName: 'a', + addon: degradedAddon, + loaded: true, + loadError: null, + }, + { + clusterName: 'b', + addon: undefined, + loaded: true, + loadError: { response: { status: 404 } }, + upstreamDetected: true, + }, + ]); + expect(result.canProceed).toBe(false); + }); +}); + +describe('isMixedSubmarinerInstall', () => { + it('returns false when both clusters use upstream Submariner', () => { + expect( + isMixedSubmarinerInstall([ + { + clusterName: 'a', + health: SubmarinerClusterHealth.UpstreamDetected, + }, + { + clusterName: 'b', + health: SubmarinerClusterHealth.UpstreamDetected, + }, + ]) + ).toBe(false); + }); + + it('returns true when only one cluster has Submariner', () => { + expect( + isMixedSubmarinerInstall([ + { + clusterName: 'a', + health: SubmarinerClusterHealth.UpstreamDetected, + }, + { + clusterName: 'b', + health: SubmarinerClusterHealth.NotInstalled, + }, + ]) + ).toBe(true); + }); +}); + +describe('isMixedSubmarinerInstallMethod', () => { + it('returns true when one cluster is ACM-managed and one is upstream', () => { + expect( + isMixedSubmarinerInstallMethod([ + { + clusterName: 'a', + health: SubmarinerClusterHealth.Healthy, + }, + { + clusterName: 'b', + health: SubmarinerClusterHealth.UpstreamDetected, + }, + ]) + ).toBe(true); + }); + + it('returns false when both clusters use the same install method', () => { + expect( + isMixedSubmarinerInstallMethod([ + { + clusterName: 'a', + health: SubmarinerClusterHealth.Healthy, + }, + { + clusterName: 'b', + health: SubmarinerClusterHealth.Healthy, + }, + ]) + ).toBe(false); + }); +}); + +describe('CIDR overlap and Globalnet requirement', () => { + it('detects overlapping and non-overlapping CIDRs', () => { + expect(doCidrsOverlap('10.128.0.0/14', '10.128.0.0/14')).toBe(true); + expect(doCidrsOverlap('10.128.0.0/14', '10.132.0.0/14')).toBe(false); + expect(doCidrsOverlap('172.30.0.0/16', '172.30.10.0/24')).toBe(true); + }); + + it('parses OpenShift network claim JSON values', () => { + expect( + extractCidrsFromNetworkClaimValue( + JSON.stringify({ + clusterNetwork: [{ cidr: '10.128.0.0/14', hostPrefix: 23 }], + serviceNetwork: ['172.30.0.0/16'], + }) + ) + ).toEqual({ + clusterCidrs: ['10.128.0.0/14'], + serviceCidrs: ['172.30.0.0/16'], + }); + }); + + it('marks Globalnet as required when pod CIDRs overlap', () => { + const requirement = evaluateGlobalnetRequirement( + [ + { + clusterName: 'a', + loaded: true, + clusterClaims: [ + { + name: 'network.openshift.io', + value: JSON.stringify({ + clusterNetwork: [{ cidr: '10.128.0.0/14' }], + serviceNetwork: ['172.30.0.0/16'], + }), + }, + ], + }, + { + clusterName: 'b', + loaded: true, + clusterClaims: [ + { + name: 'network.openshift.io', + value: JSON.stringify({ + clusterNetwork: [{ cidr: '10.128.0.0/14' }], + serviceNetwork: ['172.31.0.0/16'], + }), + }, + ], + }, + ], + undefined, + true, + false + ); + expect(requirement).toBe(GlobalnetRequirement.Required); + }); + + it('marks Globalnet as not required when CIDRs do not overlap', () => { + const requirement = evaluateGlobalnetRequirement( + [ + { + clusterName: 'a', + loaded: true, + clusterClaims: [ + { + name: 'network.openshift.io', + value: JSON.stringify({ + clusterNetwork: [{ cidr: '10.128.0.0/14' }], + serviceNetwork: ['172.30.0.0/16'], + }), + }, + ], + }, + { + clusterName: 'b', + loaded: true, + clusterClaims: [ + { + name: 'network.openshift.io', + value: JSON.stringify({ + clusterNetwork: [{ cidr: '10.132.0.0/14' }], + serviceNetwork: ['172.31.0.0/16'], + }), + }, + ], + }, + ], + undefined, + true, + false + ); + expect(requirement).toBe(GlobalnetRequirement.NotRequired); + }); + + it('falls back to Submariner Cluster CIDRs when claims are missing', () => { + const requirement = evaluateGlobalnetRequirement( + [ + { clusterName: 'a', loaded: true }, + { clusterName: 'b', loaded: true }, + ], + [ + { + metadata: { name: 'a' }, + spec: { + cluster_cidr: ['10.128.0.0/14'], + service_cidr: ['172.30.0.0/16'], + }, + }, + { + metadata: { name: 'b' }, + spec: { + cluster_cidr: ['10.128.0.0/14'], + service_cidr: ['172.31.0.0/16'], + }, + }, + ], + true, + false + ); + expect(requirement).toBe(GlobalnetRequirement.Required); + }); + + it('returns unknown when network CIDRs cannot be resolved', () => { + const requirement = evaluateGlobalnetRequirement( + [ + { clusterName: 'a', loaded: true }, + { clusterName: 'b', loaded: true }, + ], + [], + true, + false + ); + expect(requirement).toBe(GlobalnetRequirement.Unknown); + }); + + it('warns when Globalnet is required but not enabled', () => { + expect( + isGlobalnetRequiredButNotEnabled( + GlobalnetCheckStatus.Disabled, + GlobalnetRequirement.Required + ) + ).toBe(true); + expect( + evaluateGlobalnetStatus( + [{ spec: { globalnetEnabled: false } }], + true, + null, + false + ) + ).toBe(GlobalnetCheckStatus.Disabled); + }); +}); + +describe('shouldRunPrePairValidation', () => { + it('runs only for a valid Data Foundation cluster pair', () => { + expect(shouldRunPrePairValidation(MAX_ALLOWED_CLUSTERS, true, true)).toBe( + true + ); + expect( + shouldRunPrePairValidation(MAX_ALLOWED_CLUSTERS - 1, true, true) + ).toBe(false); + expect(shouldRunPrePairValidation(MAX_ALLOWED_CLUSTERS, false, true)).toBe( + false + ); + expect(shouldRunPrePairValidation(MAX_ALLOWED_CLUSTERS, true, false)).toBe( + false + ); + }); +}); diff --git a/packages/mco/utils/submariner-health.ts b/packages/mco/utils/submariner-health.ts new file mode 100644 index 000000000..52ffcbee3 --- /dev/null +++ b/packages/mco/utils/submariner-health.ts @@ -0,0 +1,423 @@ +import { + NETWORK_CLUSTER_CLAIM_NAMES, + MAX_ALLOWED_CLUSTERS, + SUBMARINER_BROKER_NAME, + SUBMARINER_CONDITION_TYPES, + GlobalnetCheckStatus, + GlobalnetRequirement, + SubmarinerClusterHealth, +} from '@odf/mco/constants'; +import { + ClusterClaim, + ClusterNetworkCidrs, + ClusterSubmarinerStatus, + ManagedClusterNetworkInfo, + SubmarinerAddOnKind, + SubmarinerBrokerKind, + SubmarinerClusterKind, +} from '@odf/mco/types'; +import { + findCondition, + getName, + isConditionStatus, +} from '@odf/shared/selectors'; +import { + K8sResourceCondition, + K8sResourceConditionStatus, +} from '@odf/shared/types'; +import { doCidrListsOverlap, isNotFoundError } from '@odf/shared/utils'; + +const findSubmarinerCondition = ( + conditions: K8sResourceCondition[] | undefined, + type: string +): K8sResourceCondition | undefined => + findCondition(conditions, type, { ignoreCase: true }); + +export const getSubmarinerClusterHealth = ( + addon: SubmarinerAddOnKind | undefined, + loaded: boolean, + loadError: unknown +): SubmarinerClusterHealth => { + if (!loaded) { + return SubmarinerClusterHealth.Checking; + } + + if (loadError && isNotFoundError(loadError)) { + return SubmarinerClusterHealth.NotInstalled; + } + + if (loadError) { + return SubmarinerClusterHealth.Degraded; + } + + if (!addon) { + return SubmarinerClusterHealth.NotInstalled; + } + + const conditions = addon.status?.conditions ?? []; + const available = findSubmarinerCondition( + conditions, + SUBMARINER_CONDITION_TYPES.AVAILABLE + ); + const connectionDegraded = findSubmarinerCondition( + conditions, + SUBMARINER_CONDITION_TYPES.CONNECTION_DEGRADED + ); + const routeAgentConnectionDegraded = findSubmarinerCondition( + conditions, + SUBMARINER_CONDITION_TYPES.ROUTE_AGENT_CONNECTION_DEGRADED + ); + const agentDegraded = findSubmarinerCondition( + conditions, + SUBMARINER_CONDITION_TYPES.AGENT_DEGRADED + ); + const gatewayNodesLabeled = findSubmarinerCondition( + conditions, + SUBMARINER_CONDITION_TYPES.GATEWAY_NODES_LABELED + ); + + if (!available) { + return SubmarinerClusterHealth.Progressing; + } + + if ( + isConditionStatus(connectionDegraded, K8sResourceConditionStatus.True) || + isConditionStatus( + routeAgentConnectionDegraded, + K8sResourceConditionStatus.True + ) || + isConditionStatus(agentDegraded, K8sResourceConditionStatus.True) || + !isConditionStatus(available, K8sResourceConditionStatus.True) || + (gatewayNodesLabeled && + !isConditionStatus(gatewayNodesLabeled, K8sResourceConditionStatus.True)) + ) { + return SubmarinerClusterHealth.Degraded; + } + + if (!agentDegraded || !connectionDegraded) { + return SubmarinerClusterHealth.Progressing; + } + + if ( + isConditionStatus(available, K8sResourceConditionStatus.True) && + isConditionStatus(agentDegraded, K8sResourceConditionStatus.False) && + isConditionStatus(connectionDegraded, K8sResourceConditionStatus.False) + ) { + return SubmarinerClusterHealth.Healthy; + } + + return SubmarinerClusterHealth.Progressing; +}; + +export const getSubmarinerFailureMessage = ( + addon: SubmarinerAddOnKind | undefined +): string | undefined => { + const conditions = addon?.status?.conditions ?? []; + const degradedCondition = conditions.find( + (condition) => + /degraded/i.test(condition.type ?? '') && + isConditionStatus(condition, K8sResourceConditionStatus.True) + ); + return degradedCondition?.message; +}; + +const isSubmarinerDetected = (health: SubmarinerClusterHealth): boolean => + health !== SubmarinerClusterHealth.NotInstalled; + +const isAcmManagedSubmariner = (health: SubmarinerClusterHealth): boolean => + isSubmarinerDetected(health) && + health !== SubmarinerClusterHealth.UpstreamDetected; + +export const isMixedSubmarinerInstall = ( + clusterStatuses: ClusterSubmarinerStatus[] +): boolean => { + const hasSubmariner = clusterStatuses.some(({ health }) => + isSubmarinerDetected(health) + ); + const allHaveSubmariner = clusterStatuses.every(({ health }) => + isSubmarinerDetected(health) + ); + return hasSubmariner && !allHaveSubmariner; +}; + +export const isMixedSubmarinerInstallMethod = ( + clusterStatuses: ClusterSubmarinerStatus[] +): boolean => + clusterStatuses.some( + ({ health }) => health === SubmarinerClusterHealth.UpstreamDetected + ) && clusterStatuses.some(({ health }) => isAcmManagedSubmariner(health)); + +export const evaluateSubmarinerPrePair = ( + clusters: Array<{ + clusterName: string; + addon: SubmarinerAddOnKind | undefined; + loaded: boolean; + loadError: unknown; + upstreamDetected?: boolean; + }> +): { + overallHealth: SubmarinerClusterHealth; + clusterStatuses: ClusterSubmarinerStatus[]; + canProceed: boolean; +} => { + const clusterStatuses: ClusterSubmarinerStatus[] = clusters.map( + ({ clusterName, addon, loaded, loadError, upstreamDetected }) => { + let health = getSubmarinerClusterHealth(addon, loaded, loadError); + if (health === SubmarinerClusterHealth.NotInstalled && upstreamDetected) { + health = SubmarinerClusterHealth.UpstreamDetected; + } + const message = + health === SubmarinerClusterHealth.Degraded + ? getSubmarinerFailureMessage(addon) + : undefined; + return { clusterName, health, message }; + } + ); + + const allLoaded = clusters.every(({ loaded }) => loaded); + const allNotInstalled = clusterStatuses.every( + ({ health }) => health === SubmarinerClusterHealth.NotInstalled + ); + const anyUpstreamDetected = clusterStatuses.some( + ({ health }) => health === SubmarinerClusterHealth.UpstreamDetected + ); + const anyDegraded = clusterStatuses.some( + ({ health }) => health === SubmarinerClusterHealth.Degraded + ); + const anyProgressing = clusterStatuses.some( + ({ health }) => + health === SubmarinerClusterHealth.Progressing || + health === SubmarinerClusterHealth.Checking + ); + const bothHaveSubmariner = clusterStatuses.every(({ health }) => + isSubmarinerDetected(health) + ); + const allAcmHealthy = clusterStatuses.every( + ({ health }) => health === SubmarinerClusterHealth.Healthy + ); + + let overallHealth = SubmarinerClusterHealth.Checking; + if (!allLoaded) { + overallHealth = SubmarinerClusterHealth.Checking; + } else if (allNotInstalled) { + overallHealth = SubmarinerClusterHealth.NotInstalled; + } else if (isMixedSubmarinerInstall(clusterStatuses)) { + overallHealth = SubmarinerClusterHealth.Degraded; + } else if (anyUpstreamDetected) { + overallHealth = SubmarinerClusterHealth.UpstreamDetected; + } else if (anyDegraded) { + overallHealth = SubmarinerClusterHealth.Degraded; + } else if (anyProgressing) { + overallHealth = SubmarinerClusterHealth.Progressing; + } else if (allAcmHealthy) { + overallHealth = SubmarinerClusterHealth.Healthy; + } + + const canProceed = + allLoaded && + (allNotInstalled || + (bothHaveSubmariner && !anyDegraded && !anyProgressing)); + + return { overallHealth, clusterStatuses, canProceed }; +}; + +export const evaluateGlobalnetStatus = ( + brokers: SubmarinerBrokerKind[] | undefined, + loaded: boolean, + loadError: unknown, + skipGlobalnetCheck: boolean +): GlobalnetCheckStatus => { + if (skipGlobalnetCheck) { + return GlobalnetCheckStatus.Skipped; + } + + if (!loaded) { + return GlobalnetCheckStatus.Checking; + } + + if (loadError || !brokers?.length) { + return GlobalnetCheckStatus.NotFound; + } + + const broker = + brokers.find((item) => getName(item) === SUBMARINER_BROKER_NAME) ?? + brokers[0]; + + return broker?.spec?.globalnetEnabled + ? GlobalnetCheckStatus.Enabled + : GlobalnetCheckStatus.Disabled; +}; + +const asStringArray = (value: unknown): string[] => { + if (typeof value === 'string') { + return [value]; + } + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((item) => { + if (typeof item === 'string') { + return [item]; + } + if ( + item && + typeof item === 'object' && + 'cidr' in item && + typeof (item as { cidr: unknown }).cidr === 'string' + ) { + return [(item as { cidr: string }).cidr]; + } + return []; + }); +}; + +export const extractCidrsFromNetworkClaimValue = ( + value?: string +): ClusterNetworkCidrs | null => { + if (!value?.trim()) { + return null; + } + + try { + const parsed = JSON.parse(value) as { + clusterNetwork?: unknown; + serviceNetwork?: unknown; + }; + const clusterCidrs = asStringArray(parsed.clusterNetwork); + const serviceCidrs = asStringArray(parsed.serviceNetwork); + + if (!clusterCidrs.length && !serviceCidrs.length) { + return null; + } + + return { clusterCidrs, serviceCidrs }; + } catch { + return null; + } +}; + +export const getClusterNetworkCidrsFromClaims = ( + clusterClaims?: ClusterClaim[] +): ClusterNetworkCidrs | null => { + const networkClaim = clusterClaims?.find((claim) => + NETWORK_CLUSTER_CLAIM_NAMES.includes( + claim.name as (typeof NETWORK_CLUSTER_CLAIM_NAMES)[number] + ) + ); + return extractCidrsFromNetworkClaimValue(networkClaim?.value); +}; + +export const getClusterNetworkCidrsFromSubmarinerCluster = ( + clusterName: string, + submarinerClusters?: SubmarinerClusterKind[] +): ClusterNetworkCidrs | null => { + const match = submarinerClusters?.find((cluster) => { + const name = getName(cluster); + const clusterId = cluster.spec?.cluster_id; + return name === clusterName || clusterId === clusterName; + }); + + if (!match) { + return null; + } + + const clusterCidrs = match.spec?.cluster_cidr ?? []; + const serviceCidrs = match.spec?.service_cidr ?? []; + if (!clusterCidrs.length && !serviceCidrs.length) { + return null; + } + + return { clusterCidrs, serviceCidrs }; +}; + +export const resolveClusterNetworkCidrs = ( + clusterName: string, + clusterClaims?: ClusterClaim[], + submarinerClusters?: SubmarinerClusterKind[] +): ClusterNetworkCidrs | null => + getClusterNetworkCidrsFromClaims(clusterClaims) ?? + getClusterNetworkCidrsFromSubmarinerCluster(clusterName, submarinerClusters); + +export const evaluateGlobalnetRequirement = ( + clusters: ManagedClusterNetworkInfo[], + submarinerClusters: SubmarinerClusterKind[] | undefined, + submarinerClustersLoaded: boolean, + skipGlobalnetCheck: boolean +): GlobalnetRequirement => { + if (skipGlobalnetCheck) { + return GlobalnetRequirement.Skipped; + } + + if ( + !clusters.every((cluster) => cluster.loaded) || + !submarinerClustersLoaded + ) { + return GlobalnetRequirement.Checking; + } + + const networkCidrs = clusters.map((cluster) => + resolveClusterNetworkCidrs( + cluster.clusterName, + cluster.clusterClaims, + submarinerClusters + ) + ); + + if (networkCidrs.some((cidrs) => !cidrs)) { + return GlobalnetRequirement.Unknown; + } + + const [left, right] = networkCidrs as ClusterNetworkCidrs[]; + const hasOverlap = + doCidrListsOverlap(left.clusterCidrs, right.clusterCidrs) || + doCidrListsOverlap(left.serviceCidrs, right.serviceCidrs); + + return hasOverlap + ? GlobalnetRequirement.Required + : GlobalnetRequirement.NotRequired; +}; + +export type GlobalnetPrePairResult = { + status: GlobalnetCheckStatus; + requirement: GlobalnetRequirement; +}; + +export const evaluateGlobalnetPrePair = ( + brokers: SubmarinerBrokerKind[] | undefined, + brokersLoaded: boolean, + brokersError: unknown, + clusters: ManagedClusterNetworkInfo[], + submarinerClusters: SubmarinerClusterKind[] | undefined, + submarinerClustersLoaded: boolean, + skipGlobalnetCheck: boolean +): GlobalnetPrePairResult => ({ + status: evaluateGlobalnetStatus( + brokers, + brokersLoaded, + brokersError, + skipGlobalnetCheck + ), + requirement: evaluateGlobalnetRequirement( + clusters, + submarinerClusters, + submarinerClustersLoaded, + skipGlobalnetCheck + ), +}); + +export const isGlobalnetRequiredButNotEnabled = ( + status: GlobalnetCheckStatus, + requirement: GlobalnetRequirement +): boolean => + requirement === GlobalnetRequirement.Required && + (status === GlobalnetCheckStatus.Disabled || + status === GlobalnetCheckStatus.NotFound); + +export const shouldRunPrePairValidation = ( + selectedClusterCount: number, + isClusterSelectionValid: boolean, + isDataFoundationBackend: boolean +): boolean => + selectedClusterCount === MAX_ALLOWED_CLUSTERS && + isClusterSelectionValid && + isDataFoundationBackend; diff --git a/packages/odf/components/utils/cidr-utils.ts b/packages/odf/components/utils/cidr-utils.ts index fa41946c3..76d025c4f 100644 --- a/packages/odf/components/utils/cidr-utils.ts +++ b/packages/odf/components/utils/cidr-utils.ts @@ -1,54 +1,14 @@ import { MON_IP_ANNOTATION } from '@odf/core/constants/network'; import { getAnnotations } from '@odf/shared/selectors'; -/** - * IPv4 CIDR utilities. IPv6 is not supported. - */ - -const ipToNumber = (ip: string): number | null => { - if (!ip) return null; - const parts = ip.trim().split('.'); - if (parts.length !== 4) return null; - let n = 0; - for (let i = 0; i < 4; i++) { - const p = parseInt(parts[i], 10); - if (isNaN(p) || p < 0 || p > 255) return null; - n = n * 256 + p; - } - return n; -}; - -/** - * Check if an IPv4 address falls within a CIDR range. - * Returns false for invalid inputs or non-IPv4. - */ -export const isIpInCidr = (ip: string, cidr: string): boolean => { - const ipNum = ipToNumber(ip?.trim()); - const [base, prefixStr] = (cidr || '').trim().split('/'); - const prefix = parseInt(prefixStr, 10); - if (ipNum === null || isNaN(prefix) || prefix < 0 || prefix > 32) - return false; - const baseNum = ipToNumber(base); - if (baseNum === null) return false; - const blockSize = 2 ** (32 - prefix); - const network = Math.floor(baseNum / blockSize) * blockSize; - const broadcast = network + blockSize - 1; - return ipNum >= network && ipNum <= broadcast; -}; - -/** - * Validate CIDR format (e.g. 192.168.0.0/24). Checks structure and octet range (0-255). - */ -export const isValidCIDRFormat = (value: string): boolean => { - if (!value?.trim()) return false; - const trimmed = value.trim(); - const cidrRegex = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/; - if (!cidrRegex.test(trimmed)) return false; - const [base, prefixStr] = trimmed.split('/'); - const mask = parseInt(prefixStr, 10); - if (isNaN(mask) || mask < 0 || mask > 32) return false; - return ipToNumber(base) !== null; -}; +export { + doCidrListsOverlap, + doCidrsOverlap, + ipToNumber, + isIpInCidr, + isValidCIDRFormat, + parseCidrRange, +} from '@odf/shared/utils'; /** * Extract mon-ip annotation from node (supports both WizardNodeState and NodeKind via getAnnotations) diff --git a/packages/shared/src/selectors/k8s.ts b/packages/shared/src/selectors/k8s.ts index fea534d9f..26b62ce7d 100644 --- a/packages/shared/src/selectors/k8s.ts +++ b/packages/shared/src/selectors/k8s.ts @@ -56,15 +56,36 @@ export const getOwnerReferences = < 'metadata.ownerReferences' ) as K8sResourceCommon['metadata']['ownerReferences']; +export const findCondition = ( + conditions: K8sResourceCondition[] | undefined, + type: string, + options?: { ignoreCase?: boolean } +): K8sResourceCondition | undefined => { + if (!conditions?.length) { + return undefined; + } + if (options?.ignoreCase) { + const normalizedType = type.toLowerCase(); + return conditions.find( + (current) => current.type?.toLowerCase() === normalizedType + ); + } + return conditions.find((current) => current.type === type); +}; + +export const isConditionStatus = ( + condition: Pick | undefined, + status: string +): boolean => condition?.status?.toLowerCase() === status.toLowerCase(); + export const getResourceCondition = < A extends K8sResourceKind = K8sResourceKind, >( resource: A, - condition: string + condition: string, + options?: { ignoreCase?: boolean } ): K8sResourceCondition => { - return resource?.status?.conditions?.find( - (current: K8sResourceCondition) => current.type === condition - ); + return findCondition(resource?.status?.conditions, condition, options); }; export const getCreationTimestamp: GetStringProperty = (resource) => diff --git a/packages/shared/src/utils/cidr.spec.ts b/packages/shared/src/utils/cidr.spec.ts new file mode 100644 index 000000000..742e0a968 --- /dev/null +++ b/packages/shared/src/utils/cidr.spec.ts @@ -0,0 +1,94 @@ +import { + doCidrListsOverlap, + doCidrsOverlap, + isIpInCidr, + isValidCIDRFormat, + parseCidrRange, +} from './cidr'; + +describe('isIpInCidr', () => { + it('returns true when IP is within CIDR (192.168.200.10 in 192.168.200.0/24)', () => { + expect(isIpInCidr('192.168.200.10', '192.168.200.0/24')).toBe(true); + }); + + it('returns false when IP is outside CIDR (192.168.200.10 not in 12.168.200.0/12)', () => { + expect(isIpInCidr('192.168.200.10', '12.168.200.0/12')).toBe(false); + }); + + it('returns true for network address', () => { + expect(isIpInCidr('192.168.200.0', '192.168.200.0/24')).toBe(true); + }); + + it('returns true for broadcast address', () => { + expect(isIpInCidr('192.168.200.255', '192.168.200.0/24')).toBe(true); + }); + + it('returns false for IP just outside range', () => { + expect(isIpInCidr('192.168.201.0', '192.168.200.0/24')).toBe(false); + }); + + it('returns false for invalid IP', () => { + expect(isIpInCidr('invalid', '192.168.200.0/24')).toBe(false); + }); + + it('returns false for invalid CIDR', () => { + expect(isIpInCidr('192.168.200.10', 'invalid')).toBe(false); + }); +}); + +describe('isValidCIDRFormat', () => { + it('returns true for valid CIDR', () => { + expect(isValidCIDRFormat('192.168.200.0/24')).toBe(true); + expect(isValidCIDRFormat('10.0.0.0/8')).toBe(true); + }); + + it('returns false for invalid octets', () => { + expect(isValidCIDRFormat('999.168.200.0/24')).toBe(false); + expect(isValidCIDRFormat('192.256.200.0/24')).toBe(false); + }); + + it('returns false for invalid format', () => { + expect(isValidCIDRFormat('192.168.200.0')).toBe(false); + expect(isValidCIDRFormat('invalid')).toBe(false); + expect(isValidCIDRFormat('192.168.200.0/33')).toBe(false); + }); +}); + +describe('parseCidrRange', () => { + it('returns network start and end for a valid CIDR', () => { + expect(parseCidrRange('192.168.200.0/24')).toEqual({ + start: 3232286720, + end: 3232286975, + }); + }); + + it('returns null for invalid CIDR', () => { + expect(parseCidrRange('invalid')).toBeNull(); + }); +}); + +describe('doCidrsOverlap', () => { + it('detects overlapping and non-overlapping CIDRs', () => { + expect(doCidrsOverlap('10.128.0.0/14', '10.128.0.0/14')).toBe(true); + expect(doCidrsOverlap('10.128.0.0/14', '10.132.0.0/14')).toBe(false); + expect(doCidrsOverlap('172.30.0.0/16', '172.30.10.0/24')).toBe(true); + }); + + it('returns false when either CIDR is invalid', () => { + expect(doCidrsOverlap('invalid', '10.0.0.0/8')).toBe(false); + }); +}); + +describe('doCidrListsOverlap', () => { + it('returns true when any pair overlaps', () => { + expect( + doCidrListsOverlap(['10.128.0.0/14'], ['10.130.0.0/16', '10.200.0.0/16']) + ).toBe(true); + }); + + it('returns false when no pairs overlap', () => { + expect(doCidrListsOverlap(['10.128.0.0/14'], ['10.200.0.0/16'])).toBe( + false + ); + }); +}); diff --git a/packages/shared/src/utils/cidr.ts b/packages/shared/src/utils/cidr.ts new file mode 100644 index 000000000..fe525f4cc --- /dev/null +++ b/packages/shared/src/utils/cidr.ts @@ -0,0 +1,71 @@ +export const ipToNumber = (ip: string): number | null => { + if (!ip) { + return null; + } + const parts = ip.trim().split('.'); + if (parts.length !== 4) { + return null; + } + let n = 0; + for (let i = 0; i < 4; i++) { + const p = parseInt(parts[i], 10); + if (isNaN(p) || p < 0 || p > 255) { + return null; + } + n = n * 256 + p; + } + return n; +}; + +export const parseCidrRange = ( + cidr: string +): { start: number; end: number } | null => { + const [ip, prefixText] = (cidr || '').trim().split('/'); + const prefix = Number(prefixText); + const ipInt = ipToNumber(ip); + if (ipInt === null || Number.isNaN(prefix) || prefix < 0 || prefix > 32) { + return null; + } + const blockSize = 2 ** (32 - prefix); + const start = Math.floor(ipInt / blockSize) * blockSize; + const end = start + blockSize - 1; + return { start, end }; +}; + +export const isIpInCidr = (ip: string, cidr: string): boolean => { + const ipNum = ipToNumber(ip?.trim()); + const range = parseCidrRange(cidr); + if (ipNum === null || !range) { + return false; + } + return ipNum >= range.start && ipNum <= range.end; +}; + +export const isValidCIDRFormat = (value: string): boolean => { + if (!value?.trim()) { + return false; + } + const trimmed = value.trim(); + const cidrRegex = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/; + if (!cidrRegex.test(trimmed)) { + return false; + } + return parseCidrRange(trimmed) !== null; +}; + +export const doCidrsOverlap = (left: string, right: string): boolean => { + const leftRange = parseCidrRange(left); + const rightRange = parseCidrRange(right); + if (!leftRange || !rightRange) { + return false; + } + return leftRange.start <= rightRange.end && rightRange.start <= leftRange.end; +}; + +export const doCidrListsOverlap = ( + left: string[] = [], + right: string[] = [] +): boolean => + left.some((leftCidr) => + right.some((rightCidr) => doCidrsOverlap(leftCidr, rightCidr)) + ); diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 22e667e27..8649a85ef 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -2,6 +2,7 @@ export * from './dashboard'; export * from './doc-utils'; export * from './humanize'; export * from './common'; +export * from './cidr'; export * from './metrics'; export * from './storage'; export * from './owner-references'; From db1eab8403f215f58f6a908b757adad5d2843d7a Mon Sep 17 00:00:00 2001 From: Timothy Asir Jeyasingh Date: Tue, 21 Jul 2026 11:19:36 +0530 Subject: [PATCH 2/2] Move third-party S3 and backend selection to Configure step Keep Clusters focused on pair selection and validation, and surface Data Foundation pre-pair, third-party S3, and hybrid backend tiles on Configure so the Agnostic DR wizard matches the multi-backend UX. Also align wizard footer button order with other wizards, reuse GreenCheckCircleIcon, share isFilled, and use Submariner condition constants for failure messages. Signed-off-by: Timothy Asir Jeyasingh --- locales/en/plugin__odf-console.json | 2 + .../create-dr-policy/CreateDRPolicyForm.tsx | 97 +++++------- .../create-dr-policy/clusters-step.tsx | 108 ++------------ .../configure-cluster-pair-step.tsx | 139 ++++++++++++++++-- .../components/create-dr-policy/footer.tsx | 17 +-- .../utils/backend-selection.ts | 55 +++++++ packages/mco/utils/submariner-health.ts | 13 +- 7 files changed, 250 insertions(+), 181 deletions(-) create mode 100644 packages/mco/components/create-dr-policy/utils/backend-selection.ts diff --git a/locales/en/plugin__odf-console.json b/locales/en/plugin__odf-console.json index c97fdaf2d..2f4d4975e 100644 --- a/locales/en/plugin__odf-console.json +++ b/locales/en/plugin__odf-console.json @@ -278,6 +278,7 @@ "All non control plane nodes are selected to handle requests to IBM Scale": "All non control plane nodes are selected to handle requests to IBM Scale", "All origins": "All origins", "All other capacity usage that are not a part of the top 5 consumers.": "All other capacity usage that are not a part of the top 5 consumers.", + "All prerequisites met": "All prerequisites met", "All processes that use application subscription is deprecated": "All processes that use application subscription is deprecated", "All required fields are not set": "All required fields are not set", "All resources are unhealthy": "All resources are unhealthy", @@ -2017,6 +2018,7 @@ "Select all": "Select all", "Select an arbiter zone": "Select an arbiter zone", "Select an existing connection": "Select an existing connection", + "Select an existing pair of clusters or two unpaired clusters.": "Select an existing pair of clusters or two unpaired clusters.", "Select at least 1 Backing Store resource": "Select at least 1 Backing Store resource", "Select at least 1 encryption level or disable encryption.": "Select at least 1 encryption level or disable encryption.", "Select at least 2 Backing Store resources": "Select at least 2 Backing Store resources", diff --git a/packages/mco/components/create-dr-policy/CreateDRPolicyForm.tsx b/packages/mco/components/create-dr-policy/CreateDRPolicyForm.tsx index d2b8968c0..28bcbc75d 100644 --- a/packages/mco/components/create-dr-policy/CreateDRPolicyForm.tsx +++ b/packages/mco/components/create-dr-policy/CreateDRPolicyForm.tsx @@ -35,6 +35,11 @@ import { CreateDRPolicyWizardFooter } from './footer'; import { PolicyStep } from './policy-step'; import { PrePairNetworkValidation } from './pre-pair-network-validation'; import { ReviewDRPolicyStep } from './review-dr-policy-step'; +import { + isFilled, + validateClustersStepInputs, + validateThirdPartyConfigureInputs, +} from './utils/backend-selection'; import { createPolicyPromises } from './utils/k8s-utils'; import { DRPolicyActionType, @@ -42,65 +47,35 @@ import { drPolicyReducer, DRPolicyState, } from './utils/reducer'; -import { - isValidBucketName, - isValidEndpoint, - isValidS3ProfileName, -} from './utils/s3-validators'; - -const isFilled = (v: string) => !!v && v.trim().length > 0; -const areS3DetailsFormatValid = (d: S3Details): boolean => - isValidBucketName(d.bucketName) && - isValidEndpoint(d.endpoint) && - isFilled(d.accessKeyId) && - isFilled(d.secretKey) && - isFilled(d.region) && - isFilled(d.s3ProfileName) && - isValidS3ProfileName(d.s3ProfileName); +const validatePolicyInputs = (state: DRPolicyState): boolean => + isFilled(state.policyName) && !!state.replicationType; -const validateClusterInputs = ( +const validateConfigureStepInputs = ( state: DRPolicyState, - allDRClustersExist = false + allDRClustersExist: boolean, + prePairValidationPassed: boolean ): boolean => { - const { - selectedClusters, - isClusterSelectionValid, - replicationBackend, - cluster1S3Details, - cluster2S3Details, - useSameS3Connection, - } = state; - - const baseValid = - isClusterSelectionValid && selectedClusters.length === MAX_ALLOWED_CLUSTERS; - - if (!baseValid) return false; - - if (replicationBackend === BackendType.ThirdParty) { - if (allDRClustersExist) return true; - const c2ProfileValid = - isFilled(cluster2S3Details.s3ProfileName) && - isValidS3ProfileName(cluster2S3Details.s3ProfileName); - return ( - areS3DetailsFormatValid(cluster1S3Details) && - c2ProfileValid && - (useSameS3Connection || areS3DetailsFormatValid(cluster2S3Details)) - ); + if (!validateClustersStepInputs(state)) { + return false; } - - return true; + if (state.replicationBackend === BackendType.DataFoundation) { + return prePairValidationPassed; + } + return validateThirdPartyConfigureInputs(state, allDRClustersExist); }; -const validatePolicyInputs = (state: DRPolicyState): boolean => - isFilled(state.policyName) && !!state.replicationType; - export const validateDRPolicyInputs = ( state: DRPolicyState, - allDRClustersExist = false + allDRClustersExist = false, + prePairValidationPassed = true ): boolean => validatePolicyInputs(state) && - validateClusterInputs(state, allDRClustersExist); + validateConfigureStepInputs( + state, + allDRClustersExist, + prePairValidationPassed + ); const convertS3ProfileToDetails = ( profile: S3StoreProfile, @@ -287,16 +262,20 @@ export const CreateDRPolicyForm: React.FC = ({ } const stepNames = CreateDRPolicyStepNames(t); + const configureStepValid = validateConfigureStepInputs( + state, + allDRClustersExist, + prePairValidationPassed + ); const stepValidity: Record = { - [CreateDRPolicyWizardSteps.Clusters]: validateClusterInputs( + [CreateDRPolicyWizardSteps.Clusters]: validateClustersStepInputs(state), + [CreateDRPolicyWizardSteps.Configure]: configureStepValid, + [CreateDRPolicyWizardSteps.Policy]: validatePolicyInputs(state), + [CreateDRPolicyWizardSteps.Review]: validateDRPolicyInputs( state, - allDRClustersExist + allDRClustersExist, + prePairValidationPassed ), - [CreateDRPolicyWizardSteps.Configure]: prePairValidation.canProceed, - [CreateDRPolicyWizardSteps.Policy]: validatePolicyInputs(state), - [CreateDRPolicyWizardSteps.Review]: - validateDRPolicyInputs(state, allDRClustersExist) && - prePairValidationPassed, }; return ( @@ -328,20 +307,20 @@ export const CreateDRPolicyForm: React.FC = ({ preSelectedClusters={preSelectedClusters} acmDoc={acmDoc} mirrorPeers={mirrorPeers} - clusterNames={clusterNames} - selectedDRClusters={selectedDRClusters} - errorMessage={s3ErrorMessage} /> = ({ @@ -29,9 +22,6 @@ export const ClustersStep: React.FC = ({ preSelectedClusters, acmDoc, mirrorPeers, - clusterNames, - selectedDRClusters, - errorMessage, }) => { const { t } = useCustomTranslation(); @@ -55,7 +45,7 @@ export const ClustersStep: React.FC = ({ showOnlyPreselected={preSelectedClusters.length > 0} /> - + @@ -70,87 +60,14 @@ export const ClustersStep: React.FC = ({ {state.selectedClusters.length === MAX_ALLOWED_CLUSTERS && ( - <> - - - - {state.isClusterSelectionValid && ( - <> - {!state.selectedClustersHaveODF && - state.selectedClusters.some( - (cluster) => cluster?.odfInfo?.storageClusterCount > 0 - ) && ( - - - - - {t( - 'All disaster recovery prerequisites are met for both clusters. Multiple storage backends are available on both of the selected clusters.' - )} - - - - - - )} - {!state.selectedClustersHaveODF && - state.replicationBackend === BackendType.ThirdParty && ( - <> - - - - - - {t( - 'Provide S3 bucket connection details for each managed cluster. If a S3 bucket is not already configured for cluster, create one and then continue.' - )} - - - - - - - )} - {!!errorMessage && ( - - - {errorMessage} - - - )} - - )} - + + + )} ); @@ -163,7 +80,4 @@ type ClustersStepProps = { preSelectedClusters: string[]; acmDoc: string; mirrorPeers: MirrorPeerKind[]; - clusterNames: string[]; - selectedDRClusters: DRClusterKind[]; - errorMessage: string; }; diff --git a/packages/mco/components/create-dr-policy/configure-cluster-pair-step.tsx b/packages/mco/components/create-dr-policy/configure-cluster-pair-step.tsx index 01d7e4fc5..241d8f6f4 100644 --- a/packages/mco/components/create-dr-policy/configure-cluster-pair-step.tsx +++ b/packages/mco/components/create-dr-policy/configure-cluster-pair-step.tsx @@ -1,37 +1,152 @@ import * as React from 'react'; import { PrePairNetworkValidationState } from '@odf/mco/hooks'; +import { DOC_VERSION, tpsDoc } from '@odf/shared'; +import { getName } from '@odf/shared/selectors'; +import { GreenCheckCircleIcon } from '@odf/shared/status'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; -import { Content, ContentVariants, Title } from '@patternfly/react-core'; +import { + Alert, + AlertVariant, + Content, + ContentVariants, + Form, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + Title, +} from '@patternfly/react-core'; +import { BackendType } from '../../constants'; +import { DRClusterKind } from '../../types'; +import { ClusterS3BucketDetailsForm } from './add-s3-bucket-details/s3-bucket-details-form'; import { PrePairNetworkValidation } from './pre-pair-network-validation'; +import { SelectReplicationBackend } from './select-replication-backend/select-replication-backend'; +import ThirdPartyStorageWarning from './third-party-storage-alert'; +import { shouldShowReplicationBackendSelection } from './utils/backend-selection'; +import { DRPolicyAction, DRPolicyState } from './utils/reducer'; type ConfigureClusterPairStepProps = { + state: DRPolicyState; + dispatch: React.Dispatch; clusterNames: string[]; + selectedDRClusters: DRClusterKind[]; validation: PrePairNetworkValidationState; docHref?: string; + errorMessage?: string; }; export const ConfigureClusterPairStep: React.FC< ConfigureClusterPairStepProps -> = ({ clusterNames, validation, docHref }) => { +> = ({ + state, + dispatch, + clusterNames, + selectedDRClusters, + validation, + docHref, + errorMessage, +}) => { const { t } = useCustomTranslation(); + const isDataFoundation = + state.replicationBackend === BackendType.DataFoundation; + const isThirdParty = state.replicationBackend === BackendType.ThirdParty; + const showBackendSelection = shouldShowReplicationBackendSelection( + state.selectedClustersHaveODF, + state.selectedClusters + ); return ( -
+
{t('Configure cluster pair')} - {t( - 'The first time a policy is created between two clusters, the clusters must be paired together.' - )} + {isThirdParty + ? t('Select an existing pair of clusters or two unpaired clusters.') + : t( + 'The first time a policy is created between two clusters, the clusters must be paired together.' + )} - -
+ + {showBackendSelection && ( + + + + + {t( + 'All disaster recovery prerequisites are met for both clusters. Multiple storage backends are available on both of the selected clusters.' + )} + + + + + + )} + + {isDataFoundation && ( + <> + {showBackendSelection && ( + + }> + {t('All prerequisites met')} + + + )} + + + )} + + {isThirdParty && ( + <> + + + + + + {t( + 'Provide S3 bucket connection details for each managed cluster. If a S3 bucket is not already configured for cluster, create one and then continue.' + )} + + + + + + + )} + + {!!errorMessage && ( + + + {errorMessage} + + + )} + ); }; diff --git a/packages/mco/components/create-dr-policy/footer.tsx b/packages/mco/components/create-dr-policy/footer.tsx index 0d7df5f22..8fd1b1bca 100644 --- a/packages/mco/components/create-dr-policy/footer.tsx +++ b/packages/mco/components/create-dr-policy/footer.tsx @@ -32,15 +32,6 @@ export const CreateDRPolicyWizardFooter: React.FC< )} - +