Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import { pluralize } from '@odf/core/components/utils';
import { ReplicationType, STORAGE_ID_LABEL_KEY } from '@odf/mco/constants';
import { STORAGE_ID_LABEL_KEY } from '@odf/mco/constants';
import { getDRPolicyResourceObj, useACMSafeFetch } from '@odf/mco/hooks';
import {
DRPolicyKind,
Expand All @@ -10,7 +10,6 @@ import {
} from '@odf/mco/types';
import {
getLabelsFromSearchResult,
getReplicationType,
queryStorageClassesUsingClusterNames,
} from '@odf/mco/utils';
import { fireManagedClusterView } from '@odf/mco/utils/managed-cluster-view';
Expand All @@ -35,6 +34,10 @@ import {
ContentVariants,
} from '@patternfly/react-core';
import { TimesIcon } from '@patternfly/react-icons';
import {
checkSyncPolicyExists,
verifyMirrorPeerExistenceForClusters,
} from './utils/cluster-peering-validators';
import {
DRPolicyAction,
DRPolicyActionType,
Expand All @@ -46,39 +49,11 @@ import '../../style.scss';
const PROVISIONER = 'provisioner';
const EXTERNAL_DEPLOYMENT_TYPE = 'external';

const checkSyncPolicyExists = (
clusters: string[],
drPolicies: DRPolicyKind[]
): boolean =>
drPolicies.some((drPolicy) => {
const { drClusters } = drPolicy.spec;
const isSyncPolicy = getReplicationType(drPolicy) === ReplicationType.SYNC;
return (
isSyncPolicy && drClusters.every((cluster) => clusters.includes(cluster))
);
});

const checkClientToODFPeering = (clusters: ManagedClusterInfoType[]): boolean =>
// ODF cluster to client peering is not supported for DR.
!!clusters[0]?.odfInfo?.storageClusterInfo?.clientInfo !==
!!clusters[1]?.odfInfo?.storageClusterInfo?.clientInfo;

const verifyMirrorPeerExistence = (
clusters: ManagedClusterInfoType[],
mirrorPeers: MirrorPeerKind[]
): boolean => {
const peerNames: string[] = clusters.map(getName);
const mirrorPeer: MirrorPeerKind = mirrorPeers.find((mirrorPeer) =>
mirrorPeer.spec?.items?.some((item) => peerNames.includes(item.clusterName))
);
const existingPeerNames =
mirrorPeer?.spec?.items?.map((item) => item.clusterName) ?? [];
// When one of the chosen clusters is already paired with another cluster, return True.
return existingPeerNames.length > 0
? existingPeerNames.sort().join(',') !== peerNames.sort().join(',')
: false;
};

const validateClusterSelection = (
clusters: ManagedClusterInfoType[],
drPolicies: DRPolicyKind[],
Expand Down Expand Up @@ -132,7 +107,7 @@ const validateClusterSelection = (
: checkClientToODFPeering(clusters),
invalidPolicyCreation:
checkSyncPolicyExists(clusters.map(getName), drPolicies) ||
verifyMirrorPeerExistence(clusters, mirrorPeers),
verifyMirrorPeerExistenceForClusters(clusters, mirrorPeers),
// Storage class validation uses ODF/Ceph provisioner allowlists.
// For third-party storage, replication is externally managed.
unSupportedStorageClasses: isThirdPartyPath
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { DRPolicyModel } from '@odf/shared';
import { DRPolicyKind, MirrorPeerKind } from '../../../types';
import {
checkSyncPolicyExists,
hasConflictingPeering,
verifyMirrorPeerExistence,
} from './cluster-peering-validators';

const mirrorPeer = (
cluster1: string,
cluster2: string
): MirrorPeerKind => ({
apiVersion: 'multicluster.odf.openshift.io/v1alpha1',
kind: 'MirrorPeer',
metadata: { name: `mirrorpeer-${cluster1}-${cluster2}` },
spec: {
items: [
{
clusterName: cluster1,
storageClusterRef: { name: 'ocs-storagecluster' },
},
{
clusterName: cluster2,
storageClusterRef: { name: 'ocs-storagecluster' },
},
],
},
});

const syncDRPolicy = (clusters: string[]): DRPolicyKind => ({
apiVersion: `${DRPolicyModel.apiGroup}/${DRPolicyModel.apiVersion}`,
kind: DRPolicyModel.kind,
metadata: { name: 'sync-policy' },
spec: {
drClusters: clusters,
schedulingInterval: '0m',
},
});

describe('cluster-peering-validators', () => {
describe('verifyMirrorPeerExistence', () => {
it('returns false when no MirrorPeer references the selected clusters', () => {
expect(
verifyMirrorPeerExistence(['A', 'B'], [mirrorPeer('C', 'D')])
).toBe(false);
});

it('returns false when the same pair already exists (reuse allowed)', () => {
expect(
verifyMirrorPeerExistence(['A', 'B'], [mirrorPeer('A', 'B')])
).toBe(false);
});

it('returns true when a selected cluster is paired with a different cluster', () => {
expect(
verifyMirrorPeerExistence(['A', 'B'], [mirrorPeer('A', 'C')])
).toBe(true);
});
});

describe('checkSyncPolicyExists', () => {
it('returns true when a sync DRPolicy already covers the selected clusters', () => {
expect(
checkSyncPolicyExists(['east-1', 'east-2'], [
syncDRPolicy(['east-1', 'east-2']),
])
).toBe(true);
});

it('returns false for async policies', () => {
expect(
checkSyncPolicyExists(['east-1', 'west-1'], [
{
...syncDRPolicy(['east-1', 'west-1']),
spec: { drClusters: ['east-1', 'west-1'], schedulingInterval: '5m' },
},
])
).toBe(false);
});
});

describe('hasConflictingPeering', () => {
it('blocks conflicting mirror peer pairs', () => {
expect(
hasConflictingPeering(['A', 'B'], [mirrorPeer('A', 'C')], [])
).toBe(true);
});

it('allows reuse of the same mirror peer pair', () => {
expect(
hasConflictingPeering(['A', 'B'], [mirrorPeer('A', 'B')], [])
).toBe(false);
});

it('allows creating a new pair when no mirror peer exists', () => {
expect(hasConflictingPeering(['A', 'B'], [], [])).toBe(false);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ReplicationType } from '@odf/mco/constants';
import { DRPolicyKind, MirrorPeerKind } from '@odf/mco/types';
import { getReplicationType } from '@odf/mco/utils';
import { getName } from '@odf/shared';
import { ManagedClusterInfoType } from './reducer';

/**
* Returns true when a selected cluster is already in a MirrorPeer with a different
* cluster than the one being paired. Reuse of the same pair (A+B again) is allowed.
*/
export const verifyMirrorPeerExistence = (
clusterNames: string[],
mirrorPeers: MirrorPeerKind[]
): boolean => {
const peerNames = clusterNames;
const mirrorPeer = mirrorPeers.find((mp) =>
mp.spec?.items?.some((item) => peerNames.includes(item.clusterName))
);
const existingPeerNames =
mirrorPeer?.spec?.items?.map((item) => item.clusterName) ?? [];
return existingPeerNames.length > 0
? existingPeerNames.sort().join(',') !== peerNames.sort().join(',')
: false;
};

export const verifyMirrorPeerExistenceForClusters = (
clusters: ManagedClusterInfoType[],
mirrorPeers: MirrorPeerKind[]
): boolean => verifyMirrorPeerExistence(clusters.map(getName), mirrorPeers);

export const checkSyncPolicyExists = (
clusters: string[],
drPolicies: DRPolicyKind[]
): boolean =>
drPolicies.some((drPolicy) => {
const { drClusters } = drPolicy.spec;
const isSyncPolicy = getReplicationType(drPolicy) === ReplicationType.SYNC;
return (
isSyncPolicy && drClusters.every((cluster) => clusters.includes(cluster))
);
});

/**
* Block conflicting pairs (e.g. A+C exists, user selects A+B).
* Allow reuse when the same pair already has a MirrorPeer or sync DRPolicy.
*/
export const hasConflictingPeering = (
clusterNames: string[],
mirrorPeers: MirrorPeerKind[],
drPolicies: DRPolicyKind[]
): boolean =>
checkSyncPolicyExists(clusterNames, drPolicies) ||
verifyMirrorPeerExistence(clusterNames, mirrorPeers);
70 changes: 66 additions & 4 deletions packages/mco/components/topology/Topology.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,17 @@ import {
useVisualizationSetup,
} from '@odf/shared/topology';
import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook';
import { MirrorPeerModel } from '@odf/shared';
import { referenceForModel } from '@odf/shared/utils';
import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk';
import { EmptyState, EmptyStateBody, Title } from '@patternfly/react-core';
import {
Alert,
AlertActionCloseButton,
AlertVariant,
EmptyState,
EmptyStateBody,
Title,
} from '@patternfly/react-core';
import { TopologyIcon } from '@patternfly/react-icons';
import {
GraphElement,
Expand All @@ -21,11 +30,13 @@ import {
import { ODFMCO_OPERATOR } from '../../constants';
import {
getManagedClusterResourceObj,
getDRPolicyResourceObj,
useProtectedAppsByCluster,
useDRPoliciesByClusterPair,
useActiveDROperations,
} from '../../hooks';
import { ACMManagedClusterKind } from '../../types';
import { ACMManagedClusterKind, DRPolicyKind, MirrorPeerKind } from '../../types';
import { hasConflictingPeering } from '../create-dr-policy/utils/cluster-peering-validators';
import { CreateDRPolicyModal } from '../create-dr-policy/CreateDRPolicyModal';
import { TopologyDataContext } from './context/TopologyContext';
import { mcoTopologyComponentFactory } from './factory/MCOStyleFactory';
Expand Down Expand Up @@ -214,6 +225,7 @@ const TopologyEmptyState: React.FC = () => {
};

const Topology: React.FC = () => {
const { t } = useCustomTranslation();
const controller = useVisualizationSetup({
componentFactory: mcoTopologyComponentFactory,
});
Expand All @@ -225,11 +237,22 @@ const Topology: React.FC = () => {
const [pairModalClusters, setPairModalClusters] = React.useState<string[]>(
[]
);
const [pairValidationError, setPairValidationError] = React.useState('');

const [managedClusters, loaded, loadError] = useK8sWatchResource<
ACMManagedClusterKind[]
>(getManagedClusterResourceObj());

const [mirrorPeers] = useK8sWatchResource<MirrorPeerKind[]>({
kind: referenceForModel(MirrorPeerModel),
isList: true,
namespaced: false,
});

const [drPolicies] = useK8sWatchResource<DRPolicyKind[]>(
getDRPolicyResourceObj()
);

const [clusterAppsMap, appsLoaded, appsLoadError] =
useProtectedAppsByCluster();

Expand All @@ -246,10 +269,32 @@ const Topology: React.FC = () => {

const handleOpenPairModal = React.useCallback(
(sourceCluster: string, targetCluster: string) => {
setPairModalClusters([sourceCluster, targetCluster]);
const clusterNames = [sourceCluster, targetCluster];
if (hasConflictingPeering(clusterNames, mirrorPeers, drPolicies)) {
setPairValidationError(
t(
'A mirror peer configuration already exists for one or more of the selected clusters, ' +
'either from an existing or deleted DR policy. To create a new DR policy with these clusters, ' +
'delete any existing mirror peer configurations associated with them and try again.'
)
);
return;
}
setPairValidationError('');
setPairModalClusters(clusterNames);
setIsPairModalOpen(true);
},
[]
[mirrorPeers, drPolicies, t]
);

const isClusterPairingBlocked = React.useCallback(
(sourceCluster: string, targetCluster: string) =>
hasConflictingPeering(
[sourceCluster, targetCluster],
mirrorPeers,
drPolicies
),
[mirrorPeers, drPolicies]
);

const handleClosePairModal = React.useCallback(() => {
Expand All @@ -268,6 +313,7 @@ const Topology: React.FC = () => {
clusterPairPoliciesMap,
clusterPairOperationsMap,
onOpenPairModal: handleOpenPairModal,
isClusterPairingBlocked,
};
}, [
managedClusters,
Expand All @@ -278,6 +324,7 @@ const Topology: React.FC = () => {
clusterPairPoliciesMap,
clusterPairOperationsMap,
handleOpenPairModal,
isClusterPairingBlocked,
]);

const hasNoClusters =
Expand All @@ -287,6 +334,21 @@ const Topology: React.FC = () => {
<TopologyDataContext.Provider value={topologyDataContextData}>
<VisualizationProvider controller={controller}>
<div className="mco-topology" id="mco-topology">
{pairValidationError && (
<Alert
className="pf-v6-u-m-md odf-alert"
variant={AlertVariant.danger}
title={t('Selected clusters cannot be used to create a DRPolicy.')}
isInline
actionClose={
<AlertActionCloseButton
onClose={() => setPairValidationError('')}
/>
}
>
{pairValidationError}
</Alert>
)}
{hasNoClusters ? (
<TopologyEmptyState />
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface ClusterContextMenuProps {
handlers: ClusterContextMenuHandlers;
otherClusters: ACMManagedClusterKind[];
rootMenuId?: string;
isPairingBlocked?: (targetClusterName: string) => boolean;
}

/**
Expand All @@ -33,6 +34,7 @@ export const ClusterContextMenu: React.FC<ClusterContextMenuProps> = ({
handlers,
otherClusters,
rootMenuId = 'cluster-menu-root',
isPairingBlocked,
}) => {
const { t } = useCustomTranslation();
const {
Expand Down Expand Up @@ -95,10 +97,12 @@ export const ClusterContextMenu: React.FC<ClusterContextMenuProps> = ({
{otherClusters.length > 0 ? (
otherClusters.map((cluster) => {
const clusterName = getName(cluster);
const pairingBlocked = isPairingBlocked?.(clusterName);
return (
<MenuItem
key={clusterName}
itemId={`cluster:${clusterName}`}
isDisabled={pairingBlocked}
onClick={() => handlePairWithCluster(clusterName)}
>
{clusterName}
Expand Down
Loading
Loading