Skip to content

Commit 0bda58b

Browse files
committed
feature(crr): orchestrate the Apply Actions chain with useChainedMutations
1 parent 5625a83 commit 0bda58b

14 files changed

Lines changed: 781 additions & 324 deletions

__mocks__/@scality/data-browser-library.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,32 @@ export const useCreateBucket = jest.fn(() => ({
3030
reset: jest.fn(),
3131
}));
3232

33+
export const useSetBucketVersioning = jest.fn(() => ({
34+
mutate: jest.fn(),
35+
mutateAsync: jest.fn(),
36+
status: 'idle',
37+
isIdle: true,
38+
isLoading: false,
39+
isSuccess: false,
40+
isError: false,
41+
data: undefined,
42+
error: null,
43+
reset: jest.fn(),
44+
}));
45+
46+
export const useSetBucketReplication = jest.fn(() => ({
47+
mutate: jest.fn(),
48+
mutateAsync: jest.fn(),
49+
status: 'idle',
50+
isIdle: true,
51+
isLoading: false,
52+
isSuccess: false,
53+
isError: false,
54+
data: undefined,
55+
error: null,
56+
reset: jest.fn(),
57+
}));
58+
3359
export const useSetBucketTagging = jest.fn(() => ({
3460
mutate: jest.fn(),
3561
mutateAsync: jest.fn(),
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { sourceStorageManagerRoleArn } from './useAssumeSourceRoleMutation';
2+
3+
describe('sourceStorageManagerRoleArn', () => {
4+
it('builds the storage-manager role ARN for the given account id', () => {
5+
expect(sourceStorageManagerRoleArn('123456789012')).toBe(
6+
'arn:aws:iam::123456789012:role/scality-internal/storage-manager-role',
7+
);
8+
});
9+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useMutation } from 'react-query';
2+
import { useSetAssumedRolePromise } from '../../../DataServiceRoleProvider';
3+
4+
/**
5+
* ARN of the platform-provisioned storage-manager role for an account — the
6+
* role the wizard assumes to run S3 operations (create bucket, put replication)
7+
* as that account. Matches the convention used by the ISV apply chain.
8+
*/
9+
export const sourceStorageManagerRoleArn = (accountId: string): string =>
10+
`arn:aws:iam::${accountId}:role/scality-internal/storage-manager-role`;
11+
12+
/**
13+
* Assumes the source account's storage-manager role so the data-browser S3
14+
* hooks target that account: the DataBrowserProvider S3 client is rebuilt from
15+
* the newly assumed role between chain steps (same mechanism as the ISV chain).
16+
*/
17+
export const useAssumeSourceRoleMutation = () => {
18+
const setRolePromise = useSetAssumedRolePromise();
19+
return useMutation({
20+
mutationFn: ({ roleArn }: { roleArn: string }) => setRolePromise({ roleArn }),
21+
});
22+
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useShellHooks } from '@scality/module-federation';
2+
import { useMutation } from 'react-query';
3+
import type { LocationV1 } from '../../../../js/managementClient/api';
4+
import { notFalsyTypeGuard } from '../../../../types/typeGuards';
5+
import { useManagementClient } from '../../../ManagementProvider';
6+
import { useInstanceId } from '../../../next-architecture/ui/AuthProvider';
7+
import { useCreateLocationMutation } from '../../hooks/useCreateLocationMutation';
8+
9+
const POLL_INTERVAL_MS = 500;
10+
const MAX_POLLS = 120; // ~60s
11+
12+
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
13+
14+
/**
15+
* Creates the CRR location and resolves only once it has been applied to the
16+
* running configuration. The subsequent replication rule references the
17+
* location by name (`StorageClass`), which cloudserver only accepts after the
18+
* overlay has reconciled — so the wizard must wait here before that step runs.
19+
*/
20+
export const useCreateCRRLocationMutation = () => {
21+
const createLocation = useCreateLocationMutation();
22+
const managementClient = useManagementClient();
23+
const { useAuth } = useShellHooks();
24+
const { getToken } = useAuth();
25+
const instanceId = useInstanceId();
26+
27+
const runningConfigurationVersion = async (): Promise<number> => {
28+
const client = notFalsyTypeGuard(managementClient);
29+
client.setToken(await getToken());
30+
return (await client.getLatestInstanceStatus(instanceId)).state?.runningConfigurationVersion ?? 0;
31+
};
32+
33+
return useMutation({
34+
mutationFn: async (location: LocationV1) => {
35+
const referenceVersion = await runningConfigurationVersion();
36+
await createLocation.mutateAsync(location);
37+
for (let attempt = 0; attempt < MAX_POLLS; attempt += 1) {
38+
if ((await runningConfigurationVersion()) > referenceVersion) {
39+
return;
40+
}
41+
await delay(POLL_INTERVAL_MS);
42+
}
43+
throw new Error('Timed out waiting for the location to be applied to the running configuration');
44+
},
45+
});
46+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { ZenkoCR } from '../../../truststore/Truststore';
2+
import { isCertificateAlreadyImported } from './useImportDestinationCertificateMutation';
3+
4+
const CERT = '-----BEGIN CERTIFICATE-----\ndest\n-----END CERTIFICATE-----';
5+
const withCerts = (certs: string[]): ZenkoCR => ({
6+
spec: { egress: { extraCACerts: certs.map((c) => ({ 'ca.crt': c })) } },
7+
});
8+
9+
describe('isCertificateAlreadyImported', () => {
10+
it('is false when the truststore has no egress configuration yet', () => {
11+
expect(isCertificateAlreadyImported(undefined, CERT)).toBe(false);
12+
expect(isCertificateAlreadyImported({ spec: {} }, CERT)).toBe(false);
13+
});
14+
15+
it('is false when other certificates are present but not this one', () => {
16+
expect(isCertificateAlreadyImported(withCerts(['some-other-cert']), CERT)).toBe(false);
17+
});
18+
19+
it('is true when the exact certificate is already in the truststore', () => {
20+
expect(isCertificateAlreadyImported(withCerts(['some-other-cert', CERT]), CERT)).toBe(true);
21+
});
22+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useMutation, useQuery } from 'react-query';
2+
import { useAddCertificateToZenkoConfigurationMutation } from '../../../../js/mutations';
3+
import { getZenkoCRQuery } from '../../../queries';
4+
import type { ZenkoCR } from '../../../truststore/Truststore';
5+
6+
/** True when the PEM is already in the source truststore, so importing it again is a no-op. */
7+
export const isCertificateAlreadyImported = (zenkoCR: ZenkoCR | undefined, certificate: string): boolean =>
8+
(zenkoCR?.spec?.egress?.extraCACerts ?? []).some((bundle) => bundle['ca.crt'] === certificate);
9+
10+
/**
11+
* Imports the destination CA certificate into the source cluster's truststore
12+
* (Zenko CR `spec.egress.extraCACerts`). Idempotent: if the certificate is
13+
* already present the step is a no-op, so re-runs and retries do not append
14+
* duplicate entries.
15+
*/
16+
export const useImportDestinationCertificateMutation = () => {
17+
const { data: zenkoCR } = useQuery(getZenkoCRQuery());
18+
const hasEgress = !!zenkoCR?.spec?.egress;
19+
const hasExtraCACerts = !!zenkoCR?.spec?.egress?.extraCACerts;
20+
const addCertificate = useAddCertificateToZenkoConfigurationMutation({ hasEgress, hasExtraCACerts });
21+
22+
return useMutation({
23+
mutationFn: async ({ certificate }: { certificate: string }) => {
24+
if (isCertificateAlreadyImported(zenkoCR, certificate)) {
25+
return;
26+
}
27+
await addCertificate.mutateAsync({ certificate });
28+
},
29+
});
30+
};

0 commit comments

Comments
 (0)