diff --git a/locales/en/plugin__odf-console.json b/locales/en/plugin__odf-console.json index 715400de8..addfce40e 100644 --- a/locales/en/plugin__odf-console.json +++ b/locales/en/plugin__odf-console.json @@ -506,6 +506,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 +555,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 +589,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 +611,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 +678,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 +798,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 +859,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", @@ -1931,6 +1941,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 +1999,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 +2195,8 @@ "StorageConsumer Name": "StorageConsumer Name", "StorageConsumers": "StorageConsumers", "Stretch Cluster": "Stretch Cluster", + "Submariner": "Submariner", + "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 +2267,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.", diff --git a/packages/mco/components/create-dr-policy/CreateDRPolicyForm.tsx b/packages/mco/components/create-dr-policy/CreateDRPolicyForm.tsx index 6b97e264b..f838be835 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 - /> - - ); -}; +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,97 @@ 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.' - )} - - - - - - - )} - - - {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/footer.tsx b/packages/mco/components/create-dr-policy/footer.tsx new file mode 100644 index 000000000..8fd1b1bca --- /dev/null +++ b/packages/mco/components/create-dr-policy/footer.tsx @@ -0,0 +1,73 @@ +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..e835980f9 --- /dev/null +++ b/packages/mco/components/create-dr-policy/pre-pair-network-validation.tsx @@ -0,0 +1,109 @@ +import * as React from 'react'; +import { SubmarinerStatus } from '@odf/mco/constants'; +import { PrePairNetworkValidationState } from '@odf/mco/hooks'; +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 = ( + status: SubmarinerStatus, + t: TFunction +): StatusLine => { + switch (status) { + case SubmarinerStatus.Checking: + return { + icon: , + title: t('Checking cluster network connectivity...'), + }; + case SubmarinerStatus.Progressing: + return { + icon: , + title: t('Cluster network configuration in progress'), + }; + case SubmarinerStatus.Healthy: + return { + icon: , + title: t('Cluster network connected'), + }; + case SubmarinerStatus.NotInstalled: + return { + icon: , + title: t('Cluster network - Skipped'), + description: t( + 'Deployment is not using submariner. Selected cluster pairs lack submariner addon' + ), + }; + case SubmarinerStatus.Inconsistent: + return { + icon: , + title: t('Degraded - Cluster unhealthy'), + description: t( + 'Submariner is not installed on one or both selected cluster pairs' + ), + showDocLink: true, + }; + case SubmarinerStatus.Degraded: + default: + return { + icon: , + title: t('Degraded - Cluster unhealthy'), + showDocLink: true, + }; + } +}; + +export const PrePairNetworkValidation: React.FC<{ + clusterNames: string[]; + validation: PrePairNetworkValidationState; + docHref?: string; +}> = ({ clusterNames, validation, docHref }) => { + const { t } = useCustomTranslation(); + const statusLine = getSubmarinerStatusLine(validation.status, t); + + return ( + } + title={t('Checking cluster network connectivity...')} + /> + } + > + + {t('Submariner')} + + + {statusLine.description && ( + + {statusLine.description} + + )} + {statusLine.showDocLink && docHref && ( + + )} + + ); +}; 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..7ba9d525d --- /dev/null +++ b/packages/mco/components/create-dr-policy/review-dr-policy-step.tsx @@ -0,0 +1,88 @@ +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; + networkStatus?: React.ReactNode; +}; + +export const ReviewDRPolicyStep: React.FC = ({ + state, + networkStatus, +}) => { + 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} + + + {networkStatus} + + + {replicationLabel} + + {state.replicationType === ReplicationType.ASYNC && ( + + {syncSchedule} + + )} + + {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..801859628 100644 --- a/packages/mco/constants/index.ts +++ b/packages/mco/constants/index.ts @@ -5,3 +5,4 @@ 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..672258659 --- /dev/null +++ b/packages/mco/constants/submariner.ts @@ -0,0 +1,21 @@ +export const SUBMARINER_ADDON_NAME = 'submariner'; + +export const SUBMARINER_ADDON_KIND = + 'addon.open-cluster-management.io~v1alpha1~ManagedClusterAddOn'; + +export const SUBMARINER_CONDITION_TYPES = { + AVAILABLE: 'SubmarinerAgentAvailable', /* also SubMariner* casing */ + CONNECTION_DEGRADED: 'SubmarinerConnectionDegraded', + ROUTE_AGENT_CONNECTION_DEGRADED: 'RouteAgentConnectionDegraded', + AGENT_DEGRADED: 'SubmarinerAgentDegraded', + GATEWAY_NODES_LABELED: 'SubmarinerGatewayNodesLabeled', +}; + +export enum SubmarinerStatus { + Checking = 'checking', + NotInstalled = 'notInstalled', + Progressing = 'progressing', + Healthy = 'healthy', + Degraded = 'degraded', + Inconsistent = 'inconsistent', +} 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..4cbc3fe44 100644 --- a/packages/mco/hooks/mco-resources.ts +++ b/packages/mco/hooks/mco-resources.ts @@ -15,7 +15,7 @@ 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 } from '../constants'; export const getDRClusterResourceObj = (props?: ClusterScopeObjectType) => ({ cluster: HUB_CLUSTER_NAME, @@ -148,6 +148,15 @@ 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, +}); + 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..dbc754d56 --- /dev/null +++ b/packages/mco/hooks/use-pre-pair-network-validation.ts @@ -0,0 +1,76 @@ +import { + MAX_ALLOWED_CLUSTERS, + SUBMARINER_ADDON_NAME, + SubmarinerStatus, +} from '@odf/mco/constants'; +import { SubmarinerAddOnKind } from '@odf/mco/types'; +import { evaluateSubmarinerPrePair } from '@odf/mco/utils/submariner-health'; +import { getName } from '@odf/shared/selectors'; +import { + getValidWatchK8sResourceObj, + isNotFoundError, +} from '@odf/shared/utils'; +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { getSubmarinerAddonListResourceObj } from './mco-resources'; + +export type PrePairNetworkValidationState = { + loaded: boolean; + loadError: unknown; + canProceed: boolean; + status: SubmarinerStatus; +}; + +const idleState: PrePairNetworkValidationState = { + loaded: true, + loadError: null, + canProceed: true, + status: SubmarinerStatus.NotInstalled, +}; + +export const usePrePairNetworkValidation = ( + clusterNames: string[], + enabled: boolean +): PrePairNetworkValidationState => { + 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 + ); + + if (!shouldWatch) { + return idleState; + } + + const { canProceed, status } = evaluateSubmarinerPrePair([ + { addon: addonA, loaded: loadedA, loadError: errorA }, + { addon: addonB, loaded: loadedB, loadError: errorB }, + ]); + + const loadError = [errorA, errorB].find( + (error) => error && !isNotFoundError(error) + ); + + return { + loaded: loadedA && loadedB, + loadError, + canProceed: !loadError && canProceed, + status, + }; +}; 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..17cdbe2db --- /dev/null +++ b/packages/mco/types/submariner.ts @@ -0,0 +1,8 @@ +import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; +import { K8sResourceCondition } from '@odf/shared/types'; + +export type SubmarinerAddOnKind = K8sResourceCommon & { + status?: { + conditions?: K8sResourceCondition[]; + }; +}; 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.ts b/packages/mco/utils/submariner-health.ts new file mode 100644 index 000000000..56c2b268b --- /dev/null +++ b/packages/mco/utils/submariner-health.ts @@ -0,0 +1,153 @@ +import { + MAX_ALLOWED_CLUSTERS, + SUBMARINER_CONDITION_TYPES, + SubmarinerStatus, +} from '@odf/mco/constants'; +import { SubmarinerAddOnKind } from '@odf/mco/types'; +import { + K8sResourceCondition, + K8sResourceConditionStatus, +} from '@odf/shared/types'; +import { isNotFoundError } from '@odf/shared/utils'; + +const findSubmarinerCondition = ( + conditions: K8sResourceCondition[] | undefined, + type: string +): K8sResourceCondition | undefined => { + if (!conditions?.length) { + return undefined; + } + const normalizedType = type.toLowerCase(); + return conditions.find( + (current) => current.type?.toLowerCase() === normalizedType + ); +}; + +const isConditionStatus = ( + condition: Pick | undefined, + status: string +): boolean => condition?.status?.toLowerCase() === status.toLowerCase(); + +// Watch 404 = resource/API absent on the hub, not a degraded install. +const isSubmarinerResourceAbsent = (loadError: unknown): boolean => + !!loadError && isNotFoundError(loadError); + +const getClusterSubmarinerStatus = ( + addon: SubmarinerAddOnKind | undefined, + loaded: boolean, + loadError: unknown +): SubmarinerStatus => { + if (!loaded) { + return SubmarinerStatus.Checking; + } + + if (isSubmarinerResourceAbsent(loadError)) { + return SubmarinerStatus.NotInstalled; + } + + if (loadError) { + return SubmarinerStatus.Degraded; + } + + if (!addon) { + return SubmarinerStatus.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 (!isConditionStatus(available, K8sResourceConditionStatus.True)) { + return SubmarinerStatus.Progressing; + } + + if ( + isConditionStatus(connectionDegraded, K8sResourceConditionStatus.True) || + isConditionStatus( + routeAgentConnectionDegraded, + K8sResourceConditionStatus.True + ) || + isConditionStatus(agentDegraded, K8sResourceConditionStatus.True) || + (gatewayNodesLabeled && + !isConditionStatus(gatewayNodesLabeled, K8sResourceConditionStatus.True)) + ) { + return SubmarinerStatus.Degraded; + } + + if (isConditionStatus(connectionDegraded, K8sResourceConditionStatus.False)) { + return SubmarinerStatus.Healthy; + } + + return SubmarinerStatus.Progressing; +}; + +export const evaluateSubmarinerPrePair = ( + clusters: Array<{ + addon: SubmarinerAddOnKind | undefined; + loaded: boolean; + loadError: unknown; + }> +): { canProceed: boolean; status: SubmarinerStatus } => { + if (!clusters.every(({ loaded }) => loaded)) { + return { canProceed: false, status: SubmarinerStatus.Checking }; + } + + const statuses = clusters.map(({ addon, loaded, loadError }) => + getClusterSubmarinerStatus(addon, loaded, loadError) + ); + + const allNotInstalled = statuses.every( + (status) => status === SubmarinerStatus.NotInstalled + ); + if (allNotInstalled) { + return { canProceed: true, status: SubmarinerStatus.NotInstalled }; + } + + const bothInstalled = statuses.every( + (status) => status !== SubmarinerStatus.NotInstalled + ); + if (!bothInstalled) { + return { canProceed: false, status: SubmarinerStatus.Inconsistent }; + } + + if (statuses.some((status) => status === SubmarinerStatus.Degraded)) { + return { canProceed: false, status: SubmarinerStatus.Degraded }; + } + + if (statuses.some((status) => status === SubmarinerStatus.Progressing)) { + return { canProceed: false, status: SubmarinerStatus.Progressing }; + } + + if (statuses.every((status) => status === SubmarinerStatus.Healthy)) { + return { canProceed: true, status: SubmarinerStatus.Healthy }; + } + + return { canProceed: false, status: SubmarinerStatus.Progressing }; +}; + +export const shouldRunPrePairValidation = ( + selectedClusterCount: number, + isClusterSelectionValid: boolean, + isDataFoundation: boolean +): boolean => + isDataFoundation && + isClusterSelectionValid && + selectedClusterCount === MAX_ALLOWED_CLUSTERS;