From 824d0ff39872ae41a41e1687c7243f1b6abe3cf6 Mon Sep 17 00:00:00 2001 From: Timothy Asir Jeyasingh Date: Thu, 23 Jul 2026 00:30:30 +0530 Subject: [PATCH] Move third-party S3 details to the Configure step Always continue to Configure after cluster selection. Data Foundation still runs Submariner checks; Third Party shows the provisioner warning and S3 details. No DF vs third-party choice UI in this epic. Step gates live in step-validation (Configure no longer re-checks the pair); Configure receives only the fields it renders. Co-authored-by: Cursor --- locales/en/plugin__odf-console.json | 3 - .../create-dr-policy/CreateDRPolicyForm.tsx | 86 ++++--------- .../create-dr-policy/clusters-step.tsx | 108 ++-------------- .../configure-cluster-pair-step.tsx | 121 +++++++++++++++--- .../backend-option.tsx | 63 --------- .../select-replication-backend.tsx | 41 ------ .../utils/step-validation.spec.ts | 117 +++++++++++++++++ .../create-dr-policy/utils/step-validation.ts | 58 +++++++++ 8 files changed, 319 insertions(+), 278 deletions(-) delete mode 100644 packages/mco/components/create-dr-policy/select-replication-backend/backend-option.tsx delete mode 100644 packages/mco/components/create-dr-policy/select-replication-backend/select-replication-backend.tsx create mode 100644 packages/mco/components/create-dr-policy/utils/step-validation.spec.ts create mode 100644 packages/mco/components/create-dr-policy/utils/step-validation.ts diff --git a/locales/en/plugin__odf-console.json b/locales/en/plugin__odf-console.json index addfce40e..c793e6446 100644 --- a/locales/en/plugin__odf-console.json +++ b/locales/en/plugin__odf-console.json @@ -268,7 +268,6 @@ "All associated LocalDisks for this FileSystem": "All associated LocalDisks for this FileSystem", "All characters are allowed except for the forward slash (\"/\").": "All characters are allowed except for the forward slash (\"/\").", "All checks": "All checks", - "All disaster recovery prerequisites are met for both clusters. Multiple storage backends are available on both of the selected clusters.": "All disaster recovery prerequisites are met for both clusters. Multiple storage backends are available on both of the selected clusters.", "All disaster recovery prerequisites met for both clusters.": "All disaster recovery prerequisites met for both clusters.", "All disk type may include HDD disks. Data Foundation does not support HDD disks as local devices. Select SSD if you plan to use Data Foundation.": "All disk type may include HDD disks. Data Foundation does not support HDD disks as local devices. Select SSD if you plan to use Data Foundation.", "All headers": "All headers", @@ -1526,7 +1525,6 @@ "No capacity data available.": "No capacity data available.", "No chart data available": "No chart data available", "No clusters found": "No clusters found", - "No clusters selected": "No clusters selected", "No common storage class found for the selected managed clusters. To create a DR policy, a common storage class must exist, if not configured already, provision a common storage class and try again.": "No common storage class found for the selected managed clusters. To create a DR policy, a common storage class must exist, if not configured already, provision a common storage class and try again.", "no compression": "no compression", "No conditions found": "No conditions found", @@ -2046,7 +2044,6 @@ "Selected BackingStores": "Selected BackingStores", "Selected capacity": "Selected capacity", "Selected clusters cannot be used to create a DRPolicy.": "Selected clusters cannot be used to create a DRPolicy.", - "Selected Clusters: {{clusters}}": "Selected Clusters: {{clusters}}", "Selected Disks": "Selected Disks", "Selected nodes": "Selected nodes", "Selected nodes are based on the StorageClass <1>{{scName}} and fulfill the stretch cluster requirements with a recommended requirement of 14 CPU and 34 GiB RAM per node.": "Selected nodes are based on the StorageClass <1>{{scName}} and fulfill the stretch cluster requirements with a recommended requirement of 14 CPU and 34 GiB RAM per node.", diff --git a/packages/mco/components/create-dr-policy/CreateDRPolicyForm.tsx b/packages/mco/components/create-dr-policy/CreateDRPolicyForm.tsx index f838be835..87b93d2a1 100644 --- a/packages/mco/components/create-dr-policy/CreateDRPolicyForm.tsx +++ b/packages/mco/components/create-dr-policy/CreateDRPolicyForm.tsx @@ -43,64 +43,27 @@ import { DRPolicyState, } from './utils/reducer'; import { - isValidBucketName, - isValidEndpoint, - isValidS3ProfileName, -} from './utils/s3-validators'; + validateClustersStepInputs, + validateConfigureStepInputs, +} from './utils/step-validation'; 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 validateClusterInputs = ( - state: DRPolicyState, - allDRClustersExist = false -): 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)) - ); - } - - return true; -}; - const validatePolicyInputs = (state: DRPolicyState): boolean => isFilled(state.policyName) && !!state.replicationType; const validateDRPolicyInputs = ( state: DRPolicyState, - allDRClustersExist = false + allDRClustersExist = false, + prePairValidationPassed = true ): boolean => validatePolicyInputs(state) && - validateClusterInputs(state, allDRClustersExist); + validateClustersStepInputs(state) && + validateConfigureStepInputs( + state, + allDRClustersExist, + prePairValidationPassed + ); const convertS3ProfileToDetails = ( profile: S3StoreProfile, @@ -288,15 +251,18 @@ export const CreateDRPolicyForm: React.FC = ({ const stepNames = CreateDRPolicyStepNames(t); const stepValidity: Record = { - [CreateDRPolicyWizardSteps.Clusters]: validateClusterInputs( + [CreateDRPolicyWizardSteps.Clusters]: validateClustersStepInputs(state), + [CreateDRPolicyWizardSteps.Configure]: validateConfigureStepInputs( state, - allDRClustersExist + allDRClustersExist, + prePairValidationPassed ), - [CreateDRPolicyWizardSteps.Configure]: prePairValidation.canProceed, [CreateDRPolicyWizardSteps.Policy]: validatePolicyInputs(state), - [CreateDRPolicyWizardSteps.Review]: - validateDRPolicyInputs(state, allDRClustersExist) && - prePairValidationPassed, + [CreateDRPolicyWizardSteps.Review]: validateDRPolicyInputs( + state, + allDRClustersExist, + prePairValidationPassed + ), }; return ( @@ -328,20 +294,24 @@ 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..4744b778d 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,126 @@ 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 { 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, + S3Details, +} from './add-s3-bucket-details/s3-bucket-details-form'; import { PrePairNetworkValidation } from './pre-pair-network-validation'; +import ThirdPartyStorageWarning from './third-party-storage-alert'; +import { DRPolicyAction, ManagedClusterInfoType } from './utils/reducer'; type ConfigureClusterPairStepProps = { + replicationBackend: BackendType; + selectedClusters: ManagedClusterInfoType[]; + cluster1S3Details: S3Details; + cluster2S3Details: S3Details; + useSameS3Connection: boolean; + dispatch: React.Dispatch; clusterNames: string[]; + selectedDRClusters: DRClusterKind[]; validation: PrePairNetworkValidationState; docHref?: string; + errorMessage?: string; }; export const ConfigureClusterPairStep: React.FC< ConfigureClusterPairStepProps -> = ({ clusterNames, validation, docHref }) => { +> = ({ + replicationBackend, + selectedClusters, + cluster1S3Details, + cluster2S3Details, + useSameS3Connection, + dispatch, + clusterNames, + selectedDRClusters, + validation, + docHref, + errorMessage, +}) => { const { t } = useCustomTranslation(); + const isDataFoundation = replicationBackend === BackendType.DataFoundation; + const isThirdParty = replicationBackend === BackendType.ThirdParty; return ( -
+
{t('Configure cluster pair')} - - - {t( - 'The first time a policy is created between two clusters, the clusters must be paired together.' - )} - - - -
+ + {isDataFoundation && ( + <> + + + {t( + 'The first time a policy is created between two clusters, the clusters must be paired together.' + )} + + + + + )} + + {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/select-replication-backend/backend-option.tsx b/packages/mco/components/create-dr-policy/select-replication-backend/backend-option.tsx deleted file mode 100644 index 2f46b4ad3..000000000 --- a/packages/mco/components/create-dr-policy/select-replication-backend/backend-option.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import * as React from 'react'; -import { BackendType } from '@odf/mco/constants'; -import { useCustomTranslation } from '@odf/shared'; -import { - Card, - CardFooter, - CardHeader, - CardTitle, -} from '@patternfly/react-core'; - -type BackendOptionProps = { - backendType: BackendType; - clusterNames?: string[]; - isSelected?: boolean; - onSelect: (selected: BackendType) => void; -}; - -export const BackendOptionCard: React.FC = ({ - backendType, - clusterNames, - isSelected, - onSelect, -}) => { - const onChange = (_event: React.FormEvent) => { - onSelect(backendType); - }; - - const { t } = useCustomTranslation(); - - const renderSelectedClusters = () => { - if (!clusterNames || clusterNames.length === 0) { - return t('No clusters selected'); - } - - const combinedNames = clusterNames.join(', '); - return t('Selected Clusters: {{clusters}}', { - clusters: combinedNames, - }); - }; - - return ( - onSelect(backendType)} - isSelected={isSelected} - > - - {backendType} - - {renderSelectedClusters()} - - ); -}; diff --git a/packages/mco/components/create-dr-policy/select-replication-backend/select-replication-backend.tsx b/packages/mco/components/create-dr-policy/select-replication-backend/select-replication-backend.tsx deleted file mode 100644 index 3c5f3dbc7..000000000 --- a/packages/mco/components/create-dr-policy/select-replication-backend/select-replication-backend.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from 'react'; -import { BackendType } from '@odf/mco/constants'; -import { Gallery } from '@patternfly/react-core'; -import { DRPolicyAction, DRPolicyActionType } from '../utils/reducer'; -import { BackendOptionCard } from './backend-option'; -import '../create-dr-policy.scss'; - -type SelectReplicationBackendProps = { - clusterNames?: string[]; - selectedKey?: BackendType; - dispatch: React.Dispatch; - doClustersHaveODF?: boolean; -}; - -export const SelectReplicationBackend: React.FC< - SelectReplicationBackendProps -> = ({ selectedKey, clusterNames, dispatch }) => { - const handleSelect = (backend: BackendType) => { - dispatch({ - type: DRPolicyActionType.SET_REPLICATION_BACKEND, - payload: backend, - }); - }; - - return ( - - - - - ); -}; diff --git a/packages/mco/components/create-dr-policy/utils/step-validation.spec.ts b/packages/mco/components/create-dr-policy/utils/step-validation.spec.ts new file mode 100644 index 000000000..de27dc835 --- /dev/null +++ b/packages/mco/components/create-dr-policy/utils/step-validation.spec.ts @@ -0,0 +1,117 @@ +import { BackendType, MAX_ALLOWED_CLUSTERS } from '@odf/mco/constants'; +import { S3Details } from '../add-s3-bucket-details/s3-bucket-details-form'; +import { DRPolicyState, drPolicyInitialState } from './reducer'; +import { + validateClustersStepInputs, + validateConfigureStepInputs, + validateThirdPartyConfigureInputs, +} from './step-validation'; + +const validS3 = (overrides: Partial = {}): S3Details => ({ + clusterName: 'cluster-1', + bucketName: 'my-bucket', + endpoint: 'https://s3.example.com', + accessKeyId: 'AKIAEXAMPLE', + secretKey: 'secret', + region: 'us-east-1', + s3ProfileName: 'profile-1', + ...overrides, +}); + +const withClusters = ( + overrides: Partial = {} +): DRPolicyState => ({ + ...drPolicyInitialState, + isClusterSelectionValid: true, + selectedClusters: new Array(MAX_ALLOWED_CLUSTERS).fill({ + metadata: { name: 'cluster' }, + }), + ...overrides, +}); + +describe('validateClustersStepInputs', () => { + it('requires a valid pair selection', () => { + expect(validateClustersStepInputs(drPolicyInitialState)).toBe(false); + expect(validateClustersStepInputs(withClusters())).toBe(true); + expect( + validateClustersStepInputs( + withClusters({ isClusterSelectionValid: false }) + ) + ).toBe(false); + }); +}); + +describe('validateThirdPartyConfigureInputs', () => { + it('is a no-op for Data Foundation', () => { + expect( + validateThirdPartyConfigureInputs({ + ...drPolicyInitialState, + replicationBackend: BackendType.DataFoundation, + }) + ).toBe(true); + }); + + it('skips S3 when both DRClusters already exist', () => { + expect( + validateThirdPartyConfigureInputs( + withClusters({ + replicationBackend: BackendType.ThirdParty, + }), + true + ) + ).toBe(true); + }); + + it('requires full S3 details for both sites unless shared', () => { + const incomplete = withClusters({ + replicationBackend: BackendType.ThirdParty, + cluster1S3Details: validS3(), + cluster2S3Details: validS3({ + bucketName: '', + s3ProfileName: 'profile-2', + }), + useSameS3Connection: false, + }); + expect(validateThirdPartyConfigureInputs(incomplete)).toBe(false); + + const shared = withClusters({ + replicationBackend: BackendType.ThirdParty, + cluster1S3Details: validS3(), + cluster2S3Details: validS3({ + bucketName: '', + endpoint: '', + accessKeyId: '', + secretKey: '', + region: '', + s3ProfileName: 'profile-2', + }), + useSameS3Connection: true, + }); + expect(validateThirdPartyConfigureInputs(shared)).toBe(true); + }); +}); + +describe('validateConfigureStepInputs', () => { + it('gates Data Foundation on Submariner pre-pair only', () => { + const state = withClusters({ + replicationBackend: BackendType.DataFoundation, + }); + expect(validateConfigureStepInputs(state, false, false)).toBe(false); + expect(validateConfigureStepInputs(state, false, true)).toBe(true); + }); + + it('gates Third Party on S3 configure inputs', () => { + const emptyS3 = withClusters({ + replicationBackend: BackendType.ThirdParty, + }); + expect(validateConfigureStepInputs(emptyS3, false, true)).toBe(false); + + const filled = withClusters({ + replicationBackend: BackendType.ThirdParty, + cluster1S3Details: validS3(), + cluster2S3Details: validS3({ s3ProfileName: 'profile-2' }), + useSameS3Connection: false, + }); + expect(validateConfigureStepInputs(filled, false, true)).toBe(true); + }); +}); diff --git a/packages/mco/components/create-dr-policy/utils/step-validation.ts b/packages/mco/components/create-dr-policy/utils/step-validation.ts new file mode 100644 index 000000000..2e05b2474 --- /dev/null +++ b/packages/mco/components/create-dr-policy/utils/step-validation.ts @@ -0,0 +1,58 @@ +import { BackendType, MAX_ALLOWED_CLUSTERS } from '@odf/mco/constants'; +import { S3Details } from '../add-s3-bucket-details/s3-bucket-details-form'; +import { DRPolicyState } from './reducer'; +import { + isValidBucketName, + isValidEndpoint, + isValidS3ProfileName, +} from './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); + +export const validateClustersStepInputs = (state: DRPolicyState): boolean => + state.isClusterSelectionValid && + state.selectedClusters.length === MAX_ALLOWED_CLUSTERS; + +/** Configure-step gate only (pair already required to reach this step). */ +export const validateConfigureStepInputs = ( + state: DRPolicyState, + allDRClustersExist: boolean, + prePairValidationPassed: boolean +): boolean => { + if (state.replicationBackend === BackendType.DataFoundation) { + return prePairValidationPassed; + } + return validateThirdPartyConfigureInputs(state, allDRClustersExist); +}; + +export const validateThirdPartyConfigureInputs = ( + state: DRPolicyState, + allDRClustersExist = false +): boolean => { + if (state.replicationBackend !== BackendType.ThirdParty) { + return true; + } + if (allDRClustersExist) { + return true; + } + + const { cluster1S3Details, cluster2S3Details, useSameS3Connection } = state; + const c2ProfileValid = + isFilled(cluster2S3Details.s3ProfileName) && + isValidS3ProfileName(cluster2S3Details.s3ProfileName); + + return ( + areS3DetailsFormatValid(cluster1S3Details) && + c2ProfileValid && + (useSameS3Connection || areS3DetailsFormatValid(cluster2S3Details)) + ); +};