Skip to content
Merged
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
Expand Up @@ -192,6 +192,7 @@ export const ApplyActionsStep = (props: Props) => {
sourceBucketName: sourceBucketName ?? '',
targetBucketName: props.targetBucketName ?? '',
locationName,
destinationAccountName: destinationAccountName ?? '',
destinationRoleArn: result.roleArn,
});
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import type { SetupResult } from '../../api/types';
import { buildCRRLocation, buildCRRLocationName } from './crrLocation';
import { buildCRRLocation, buildCRRLocationName, buildCRRReplicationRuleId } from './crrLocation';

describe('buildCRRLocationName', () => {
it('uses the destination host from the connection URL, without scheme or port (management-network)', () => {
expect(buildCRRLocationName({ destinationAccountName: 'dest-acct', url: 'https://10.0.0.42:8443' })).toBe(
'dest-acct-10-0-0-42',
'location-dest-acct-10-0-0-42',
);
});

it('uses the base domain host (data-network)', () => {
expect(buildCRRLocationName({ destinationAccountName: 'dest-acct', baseDomain: 's3.example.com' })).toBe(
'dest-acct-s3-example-com',
'location-dest-acct-s3-example-com',
);
});
});

describe('buildCRRReplicationRuleId', () => {
it('names the rule after the destination account', () => {
expect(buildCRRReplicationRuleId('dest-acct')).toBe('replication-dest-acct');
});
});

describe('buildCRRLocation', () => {
const result: SetupResult = {
endpoint: 'https://10.0.0.42:8443',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import type { SetupResult } from '../../api/types';
const sanitizeHost = (host: string): string => host.replace(/[^a-zA-Z0-9-]/g, '-');

/**
* Derives the CRR location name as `<destinationAccountName>-<host>`, where host
* is the destination IP/hostname only (no scheme, no port). Management-network
* Derives the CRR location name as `location-<destinationAccountName>-<host>`, where
* host is the destination IP/hostname only (no scheme, no port). Management-network
* uses the connection URL's host; data-network uses the base domain.
*/
export const buildCRRLocationName = ({
Expand All @@ -23,9 +23,12 @@ export const buildCRRLocationName = ({
baseDomain?: string;
}): string => {
const host = url ? new URL(url).hostname : (baseDomain ?? '');
return `${destinationAccountName}-${sanitizeHost(host)}`;
return `location-${destinationAccountName}-${sanitizeHost(host)}`;
};

export const buildCRRReplicationRuleId = (destinationAccountName: string): string =>
`replication-${destinationAccountName}`;

/**
* Builds the `location-scality-crr-v1` configuration from the backend setup
* result: the destination S3/STS endpoints and the crr-user access keys the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ describe('buildCRRReplicationConfiguration', () => {
const config = buildCRRReplicationConfiguration({
sourceBucketName: 'source-bucket',
targetBucketName: 'target-bucket',
locationName: 'crr-paris',
locationName: 'location-paris-dest-10-0-0-42',
destinationAccountName: 'paris-dest',
destinationRoleArn: 'arn:aws:iam::123456789012:role/crr-role',
});

Expand All @@ -20,8 +21,9 @@ describe('buildCRRReplicationConfiguration', () => {

it('routes replication to the target bucket via the CRR location name', () => {
const [rule] = config.ReplicationConfiguration?.Rules ?? [];
expect(rule.ID).toBe('replication-paris-dest');
expect(rule.Status).toBe('Enabled');
expect(rule.Destination.Bucket).toBe('arn:aws:s3:::target-bucket');
expect(rule.Destination.StorageClass).toBe('crr-paris');
expect(rule.Destination.StorageClass).toBe('location-paris-dest-10-0-0-42');
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PutBucketReplicationCommandInput, StorageClass } from '@aws-sdk/client-s3';
import { buildCRRReplicationRuleId } from './crrLocation';

/**
* Source-side placeholder replication role. Per the ARTESCA CRR procedure the
Expand All @@ -11,6 +12,7 @@ type BuildReplicationConfigurationInput = {
sourceBucketName: string;
targetBucketName: string;
locationName: string;
destinationAccountName: string;
destinationRoleArn: string;
};

Expand All @@ -23,14 +25,15 @@ export const buildCRRReplicationConfiguration = ({
sourceBucketName,
targetBucketName,
locationName,
destinationAccountName,
destinationRoleArn,
}: BuildReplicationConfigurationInput): PutBucketReplicationCommandInput => ({
Bucket: sourceBucketName,
ReplicationConfiguration: {
Role: `${SOURCE_REPLICATION_ROLE_ARN},${destinationRoleArn}`,
Rules: [
{
ID: `crr-${locationName}`,
ID: buildCRRReplicationRuleId(destinationAccountName),
Status: 'Enabled',
Prefix: '',
Destination: {
Expand Down
53 changes: 53 additions & 0 deletions src/react/locations/CRRSetupWizard/steps/SummaryStep.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useLocation } from 'react-router';
import { Wrapper } from '../../../utils/testUtil';
import { SummaryStep } from './SummaryStep';

const CurrentPath = () => <div data-testid="path">{useLocation().pathname}</div>;

const renderSummary = (props: Parameters<typeof SummaryStep>[0]) =>
render(
<>
<SummaryStep {...props} />
<CurrentPath />
</>,
{ wrapper: Wrapper },
);

const baseProps = {
accountName: 'source-account',
destinationAccountName: 'crr-dest',
url: 'https://10.0.0.42:8443',
createReplicationRule: true,
sourceBucketName: 'my-source',
targetBucketName: 'my-target',
};

describe('SummaryStep', () => {
it('recaps the replication rule and both buckets, then sends the user to the new source bucket on Finish', async () => {
renderSummary(baseProps);

expect(screen.getByText('Location Name')).toBeInTheDocument();
expect(screen.getByText('Replication Rule')).toBeInTheDocument();
expect(screen.getByText('my-source')).toBeInTheDocument();
expect(screen.getByText('my-target')).toBeInTheDocument();

await userEvent.click(screen.getByRole('button', { name: /Finish/i }));

expect(screen.getByTestId('path')).toHaveTextContent('/accounts/source-account/buckets/my-source');
});

it('recaps only the location and sends the user to the buckets list on Finish when no rule was configured', async () => {
renderSummary({ ...baseProps, createReplicationRule: false });

expect(screen.getByText('Location Name')).toBeInTheDocument();
expect(screen.queryByText('Replication Rule')).not.toBeInTheDocument();
expect(screen.queryByText('my-source')).not.toBeInTheDocument();
expect(screen.queryByText('my-target')).not.toBeInTheDocument();

await userEvent.click(screen.getByRole('button', { name: /Finish/i }));

expect(screen.getByTestId('path')).toHaveTextContent('/buckets');
});
});
80 changes: 70 additions & 10 deletions src/react/locations/CRRSetupWizard/steps/SummaryStep.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,70 @@
import { Banner, Icon } from '@scality/core-ui';
import { Box } from '@scality/core-ui/dist/next';

export const SummaryStep = () => (
<Box padding="r24">
<Banner variant="base" icon={<Icon name="Info-circle" />} title="Summary">
Displays the resulting credentials once the setup completes. It lands in the next brick.
</Banner>
</Box>
);
import { Form, FormGroup, FormSection, Text } from '@scality/core-ui';
import { Button } from '@scality/core-ui/dist/next';
import { useBasenameRelativeNavigate } from '@scality/module-federation';
import { buildCRRLocationName, buildCRRReplicationRuleId } from './ApplyActionsStep/crrLocation';
import type { ConfigureFormValues } from './ConfigureStep/schema';

type Props = Partial<ConfigureFormValues>;

export const SummaryStep = ({
accountName,
destinationAccountName,
url,
baseDomain,
createReplicationRule,
sourceBucketName,
targetBucketName,
}: Props) => {
const navigate = useBasenameRelativeNavigate();
const onFinish = () =>
navigate(
createReplicationRule && sourceBucketName && accountName
? `/accounts/${accountName}/buckets/${sourceBucketName}`
: '/buckets',
);
const locationName = buildCRRLocationName({
destinationAccountName: destinationAccountName ?? '',
url: url || undefined,
baseDomain: baseDomain || undefined,
});

const intro = createReplicationRule
? 'Cross-Region Replication is now configured. New objects added to the source bucket are replicated to the destination automatically.'
: 'Your Cross-Region Replication location is now ready. You can enable replication on a bucket whenever you need it.';

const details = [
{ id: 'location-name', label: 'Location Name', value: locationName },
...(createReplicationRule
? [
{
id: 'replication-rule',
label: 'Replication Rule',
value: buildCRRReplicationRuleId(destinationAccountName ?? ''),
},
{ id: 'source-bucket', label: 'Source Bucket', value: sourceBucketName ?? '' },
{ id: 'target-bucket', label: 'Target Bucket', value: targetBucketName ?? '' },
]
: []),
];

return (
<Form
layout={{ title: 'Summary', kind: 'page' }}
requireMode="all"
rightActions={<Button variant="primary" type="button" label="Finish" onClick={onFinish} />}
>
<Text isEmphazed>{intro}</Text>
<FormSection title={{ name: 'Details' }}>
{details.map((detail) => (
<FormGroup
key={detail.id}
id={detail.id}
label={detail.label}
content={<Text>{detail.value}</Text>}
required
/>
))}
</FormSection>
</Form>
);
};
Loading