Skip to content

Commit 0598b15

Browse files
committed
feature(crr): add the DNS fallback modal for unresolved destination hostnames
1 parent 8dbb27e commit 0598b15

5 files changed

Lines changed: 171 additions & 5 deletions

File tree

src/react/locations/CRRSetupWizard/CRRSetupWizard.test.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,49 @@ describe('CRRSetupWizard — Configure step', () => {
9494

9595
expect(await screen.findByText(/destination cluster did not respond/i)).toBeInTheDocument();
9696
});
97+
98+
it('opens the DNS fallback modal on unresolved hosts and retries with one IP applied to all of them', async () => {
99+
let verifyCalls = 0;
100+
let retryBody: { hostAliases?: { hostname: string; ip: string }[] } | undefined;
101+
server.use(
102+
rest.post(VERIFY_URL, (req, res, ctx) => {
103+
verifyCalls += 1;
104+
if (verifyCalls === 1) {
105+
return res(
106+
ctx.status(502),
107+
ctx.set('Content-Type', 'application/problem+json'),
108+
ctx.body(
109+
JSON.stringify({
110+
type: 'about:blank',
111+
title: 'DNS resolution failed',
112+
status: 502,
113+
code: 'DestinationDnsResolutionFailed',
114+
unresolvedHosts: ['s3.dest.local', 'iam.dest.local'],
115+
}),
116+
),
117+
);
118+
}
119+
retryBody = req.body as { hostAliases?: { hostname: string; ip: string }[] };
120+
return res(ctx.json({ ok: true, mode: 'management-network', instanceName: 'ageless-valley' }));
121+
}),
122+
);
123+
render(<CRRSetupWizard />, { wrapper: Wrapper });
124+
125+
await fillValidForm();
126+
await userEvent.click(screen.getByRole('button', { name: /Check Connection/i }));
127+
128+
// The DNS failure surfaces the fallback modal listing every host that could not be resolved.
129+
expect(await screen.findByText('• s3.dest.local')).toBeInTheDocument();
130+
expect(screen.getByText('• iam.dest.local')).toBeInTheDocument();
131+
132+
await userEvent.type(screen.getByRole('textbox', { name: /Destination cluster IP/i }), '10.0.0.9');
133+
await userEvent.click(screen.getByRole('button', { name: /Retry Connection/i }));
134+
135+
expect(await screen.findByText(/Destination reachable/i)).toBeInTheDocument();
136+
// The single IP fans out to an alias for each unresolved host.
137+
expect(retryBody?.hostAliases).toEqual([
138+
{ hostname: 's3.dest.local', ip: '10.0.0.9' },
139+
{ hostname: 'iam.dest.local', ip: '10.0.0.9' },
140+
]);
141+
});
97142
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Modal, Stack, Text } from '@scality/core-ui';
2+
import { Box } from '@scality/core-ui/dist/components/box/Box';
3+
import { Button, Input } from '@scality/core-ui/dist/next';
4+
import { useEffect, useState } from 'react';
5+
import type { HostAlias } from './api/types';
6+
7+
const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
8+
9+
type Props = {
10+
isOpen: boolean;
11+
unresolvedHosts: string[];
12+
initialAliases: HostAlias[];
13+
onSubmit: (aliases: HostAlias[]) => void;
14+
onCancel: () => void;
15+
};
16+
17+
export const DnsFallbackModal = ({ isOpen, unresolvedHosts, initialAliases, onSubmit, onCancel }: Props) => {
18+
const seedIp = () => initialAliases.find((alias) => unresolvedHosts.includes(alias.hostname))?.ip ?? '';
19+
const [ip, setIp] = useState(seedIp);
20+
21+
const hostsKey = unresolvedHosts.join('|');
22+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-seed only when the host set changes, capturing initialAliases at that moment
23+
useEffect(() => {
24+
setIp(seedIp());
25+
}, [hostsKey]);
26+
27+
const trimmed = ip.trim();
28+
const isValid = IPV4.test(trimmed);
29+
const plural = unresolvedHosts.length > 1;
30+
31+
return (
32+
<Modal
33+
isOpen={isOpen}
34+
close={onCancel}
35+
title="Resolve destination hostnames"
36+
footer={
37+
<Box style={{ display: 'flex', justifyContent: 'flex-end' }}>
38+
<Stack>
39+
<Button variant="outline" label="Cancel" onClick={onCancel} />
40+
<Button
41+
variant="primary"
42+
label="Retry Connection"
43+
disabled={!isValid}
44+
onClick={() => onSubmit(unresolvedHosts.map((hostname) => ({ hostname, ip: trimmed })))}
45+
/>
46+
</Stack>
47+
</Box>
48+
}
49+
>
50+
<Stack direction="vertical" gap="r16" style={{ maxWidth: '35rem' }}>
51+
<Text>
52+
The destination could not resolve the hostname{plural ? 's' : ''} below. Enter the destination cluster IP to
53+
reach {plural ? 'them' : 'it'}; it is used only for this setup, no DNS change is made.
54+
</Text>
55+
<Stack direction="vertical" gap="r4">
56+
{unresolvedHosts.map((host) => (
57+
<Text key={host} variant="Basic">
58+
{host}
59+
</Text>
60+
))}
61+
</Stack>
62+
<Stack direction="vertical" gap="r4">
63+
<Text>Destination cluster IP</Text>
64+
<Input
65+
id="dns-fallback-ip"
66+
aria-label="Destination cluster IP"
67+
noPlaceholderPrefix
68+
placeholder="10.0.0.10"
69+
value={ip}
70+
onChange={(e) => setIp(e.target.value)}
71+
/>
72+
{trimmed !== '' && !isValid && <Text variant="Smaller">Enter a valid IPv4 address</Text>}
73+
</Stack>
74+
</Stack>
75+
</Modal>
76+
);
77+
};

src/react/locations/CRRSetupWizard/steps/ApplyActionsStep/ApplyActionsStep.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const VALUES: ConfigureFormValues = {
4040
password: 'super-secret',
4141
certificate: '-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----',
4242
destinationAccountName: 'crr-dest',
43+
hostAliases: [],
4344
createReplicationRule: true,
4445
sourceBucketName: 'crr-src-bucket',
4546
targetBucketName: 'crr-target-bucket',

src/react/locations/CRRSetupWizard/steps/ConfigureStep/ConfigureStep.tsx

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { Form, Icon, Stack, useToast } from '@scality/core-ui';
22
import { useStepper } from '@scality/core-ui/dist/components/steppers/Stepper.component';
33
import { Button } from '@scality/core-ui/dist/next';
44
import { useBasenameRelativeNavigate } from '@scality/module-federation';
5-
import { useRef } from 'react';
5+
import { useRef, useState } from 'react';
66
import { FormProvider, useForm } from 'react-hook-form';
77
import { ServiceError } from '../../api/crrConfiguratorClient';
8-
import type { ProblemCode, VerifyRequestBody, VerifyResponse } from '../../api/types';
8+
import type { HostAlias, ProblemCode, VerifyRequestBody, VerifyResponse } from '../../api/types';
9+
import { DnsFallbackModal } from '../../DnsFallbackModal';
910
import { useCRRConfigurationVerifyMutation } from '../../hooks/useCRRConfigurationVerifyMutation';
1011
import { DestinationAccountSection } from './DestinationAccountSection';
1112
import { DestinationConnectionSection } from './DestinationConnectionSection';
@@ -34,12 +35,27 @@ const errorMessage = (error: unknown): string => {
3435
return (error as Error)?.message ?? 'Connection to the destination failed.';
3536
};
3637

38+
const unresolvedHostsFrom = (error: unknown): string[] | null => {
39+
if (error instanceof ServiceError && error.code === 'DestinationDnsResolutionFailed') {
40+
const hosts = error.problem.unresolvedHosts;
41+
if (hosts && hosts.length > 0) return hosts;
42+
}
43+
return null;
44+
};
45+
46+
const mergeAliases = (existing: HostAlias[], added: HostAlias[]): HostAlias[] => {
47+
const byHost = new Map(existing.map((alias) => [alias.hostname, alias]));
48+
for (const alias of added) byHost.set(alias.hostname, alias);
49+
return [...byHost.values()];
50+
};
51+
3752
export const ConfigureStep = () => {
3853
const { next } = useStepper(CONFIGURE_STEP_INDEX);
3954
const navigate = useBasenameRelativeNavigate();
4055
const { showToast } = useToast();
4156
const verify = useCRRConfigurationVerifyMutation();
4257
const lastVerifiedRef = useRef<string | null>(null);
58+
const [dnsFallbackHosts, setDnsFallbackHosts] = useState<string[] | null>(null);
4359

4460
const formMethods = useForm<ConfigureFormValues>({
4561
mode: 'all',
@@ -49,10 +65,20 @@ export const ConfigureStep = () => {
4965
const {
5066
handleSubmit,
5167
getValues,
68+
setValue,
5269
trigger,
5370
formState: { isValid },
5471
} = formMethods;
5572

73+
const handleVerifyError = (error: unknown) => {
74+
const hosts = unresolvedHostsFrom(error);
75+
if (hosts) {
76+
setDnsFallbackHosts(hosts);
77+
return;
78+
}
79+
showToast({ open: true, status: 'error', message: errorMessage(error) });
80+
};
81+
5682
const runVerify = async (body: VerifyRequestBody): Promise<VerifyResponse> => {
5783
const response = await verify.mutateAsync(body);
5884
lastVerifiedRef.current = JSON.stringify(body);
@@ -85,7 +111,7 @@ export const ConfigureStep = () => {
85111
await runVerify(body);
86112
showToast({ open: true, status: 'success', message: 'Destination reachable' });
87113
} catch (error) {
88-
showToast({ open: true, status: 'error', message: errorMessage(error) });
114+
handleVerifyError(error);
89115
}
90116
};
91117

@@ -100,12 +126,23 @@ export const ConfigureStep = () => {
100126
const response = await runVerify(body);
101127
next({ ...values, destinationInstanceName: destinationInstanceNameFrom(response) });
102128
} catch (error) {
103-
showToast({ open: true, status: 'error', message: errorMessage(error) });
129+
handleVerifyError(error);
104130
}
105131
});
106132

107133
return (
108134
<FormProvider {...formMethods}>
135+
<DnsFallbackModal
136+
isOpen={dnsFallbackHosts !== null}
137+
unresolvedHosts={dnsFallbackHosts ?? []}
138+
initialAliases={getValues('hostAliases')}
139+
onCancel={() => setDnsFallbackHosts(null)}
140+
onSubmit={(aliases) => {
141+
setValue('hostAliases', mergeAliases(getValues('hostAliases'), aliases));
142+
setDnsFallbackHosts(null);
143+
onCheckConnection();
144+
}}
145+
/>
109146
<Form
110147
onSubmit={onContinue}
111148
requireMode="partial"

src/react/locations/CRRSetupWizard/steps/ConfigureStep/schema.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Joi from 'joi';
22
import type { FieldErrors, Resolver } from 'react-hook-form';
33
import { accountNameValidationSchema } from '../../../../account/AccountCreate';
4-
import type { StartSetupBody, VerifyRequestBody } from '../../api/types';
4+
import type { HostAlias, StartSetupBody, VerifyRequestBody } from '../../api/types';
55

66
export type AccountNameType = 'create' | 'existing';
77
export type ConnectionMode = 'management-network' | 'data-network';
@@ -21,6 +21,8 @@ export type ConfigureFormValues = {
2121

2222
destinationAccountName: string;
2323

24+
hostAliases: HostAlias[];
25+
2426
createReplicationRule: boolean;
2527
sourceBucketName: string;
2628
targetBucketName: string;
@@ -38,6 +40,7 @@ export const defaultConfigureValues: ConfigureFormValues = {
3840
password: '',
3941
certificate: '',
4042
destinationAccountName: '',
43+
hostAliases: [],
4144
createReplicationRule: false,
4245
sourceBucketName: '',
4346
targetBucketName: '',
@@ -88,6 +91,8 @@ export const configureSchema = Joi.object<ConfigureFormValues>({
8891

8992
destinationAccountName: accountNameValidationSchema,
9093

94+
hostAliases: Joi.array().default([]),
95+
9196
createReplicationRule: Joi.boolean().required(),
9297
sourceBucketName: Joi.when('createReplicationRule', {
9398
is: true,
@@ -145,6 +150,7 @@ export const toVerifyBody = (values: ConfigureFormValues): VerifyRequestBody =>
145150
adminPassword: values.password,
146151
},
147152
destinationCertificate: values.certificate,
153+
...(values.hostAliases?.length ? { hostAliases: values.hostAliases } : {}),
148154
});
149155

150156
export const toStartSetupBody = (values: ConfigureFormValues): StartSetupBody => {

0 commit comments

Comments
 (0)