Skip to content

Commit 1436766

Browse files
committed
CCM-10057: backup to S3
1 parent f21865a commit 1436766

File tree

9 files changed

+1145
-86
lines changed

9 files changed

+1145
-86
lines changed

data-migration/user-transfer/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
{
22
"dependencies": {
33
"@aws-sdk/client-dynamodb": "^3.806.0",
4+
"@aws-sdk/client-s3": "^3.810.0",
5+
"@aws-sdk/client-sts": "^3.810.0",
46
"yargs": "^17.7.2"
57
},
68
"description": "Transfers template ownership from one user to another",

data-migration/user-transfer/src/__tests__/utils/backup-utils.test.ts

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { backupData } from '@/src/utils/backup-utils';
2-
import * as fileSystem from 'node:fs';
2+
import { getAccountId } from '@/src/utils/sts-utils';
3+
import { writeJsonToFile } from '@/src/utils/s3-utils';
34

45
const mockItem1 = {
56
templateType: {
@@ -31,25 +32,20 @@ const mockItem2 = {
3132
},
3233
};
3334

34-
jest.mock('node:fs', () => ({
35-
__esModule: true,
36-
...jest.requireActual('node:fs'),
37-
}));
38-
39-
const writeFileSyncSpy = jest.spyOn(fileSystem, 'writeFileSync');
40-
const existsSyncSpy = jest.spyOn(fileSystem, 'existsSync');
41-
const mkdirSyncSpy = jest.spyOn(fileSystem, 'mkdirSync');
35+
jest.mock('@/src/utils/sts-utils');
36+
jest.mock('@/src/utils/s3-utils');
4237

4338
describe('backup-utils', () => {
4439
describe('backupData', () => {
45-
test('should backup data', () => {
40+
test('should backup data', async () => {
4641
// arrange
4742
jest.useFakeTimers();
4843
jest.setSystemTime(new Date('2025-05-13'));
4944

50-
writeFileSyncSpy.mockImplementation(() => {});
51-
existsSyncSpy.mockImplementation(() => false);
52-
mkdirSyncSpy.mockImplementation(() => '');
45+
jest
46+
.mocked(getAccountId)
47+
.mockImplementation(() => Promise.resolve('000000000000'));
48+
const mockedWriteJsonToFile = jest.mocked(writeJsonToFile);
5349

5450
const testParameters = {
5551
destinationOwner: 'def-456',
@@ -59,24 +55,25 @@ describe('backup-utils', () => {
5955

6056
const mockItems = [mockItem1, mockItem2];
6157
const expectedBackupFilePath =
62-
'./backups/usr_tsfr-2025-05-13T00:00:00.000Z-env-testenv-src-abc-123-dest-def-456.json';
58+
'user-transfer/testenv/2025_05_13_00_00_00_000Z-source-abc-123-destination-def-456.json';
59+
const expectedBucketName =
60+
'nhs-notify-000000000000-eu-west-2-main-acct-migration-backup';
61+
const expectedContent = JSON.stringify(mockItems);
6362

6463
// act
65-
backupData(mockItems, testParameters);
64+
await backupData(mockItems, testParameters);
6665

6766
// assert
68-
expect(mkdirSyncSpy).toHaveBeenCalledWith('./backups');
69-
expect(writeFileSyncSpy).toHaveBeenCalledWith(
67+
expect(mockedWriteJsonToFile).toHaveBeenCalledWith(
7068
expectedBackupFilePath,
71-
JSON.stringify(mockItems)
69+
expectedContent,
70+
expectedBucketName
7271
);
7372
});
7473

75-
test('should handle existing backup dir', () => {
74+
test('should no-op the backup when no items have been found', async () => {
7675
// arrange
77-
writeFileSyncSpy.mockImplementation(() => {});
78-
existsSyncSpy.mockImplementation(() => true);
79-
mkdirSyncSpy.mockImplementation(() => '');
76+
const mockedWriteJsonToFile = jest.mocked(writeJsonToFile);
8077

8178
const testParameters = {
8279
destinationOwner: 'def-456',
@@ -85,10 +82,10 @@ describe('backup-utils', () => {
8582
};
8683

8784
// act
88-
backupData([], testParameters);
85+
await backupData([], testParameters);
8986

9087
// assert
91-
expect(mkdirSyncSpy).not.toHaveBeenCalled();
88+
expect(mockedWriteJsonToFile).not.toHaveBeenCalled();
9289
});
9390
});
9491

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { writeJsonToFile } from '@/src/utils/s3-utils';
2+
import { S3Client } from '@aws-sdk/client-s3';
3+
4+
jest.mock('@aws-sdk/client-s3', () => ({
5+
...jest.requireActual('@aws-sdk/client-s3'),
6+
}));
7+
8+
describe('s3-utils', () => {
9+
describe('writeJsonToFile', () => {
10+
test('should write JSON to an S3 object', async () => {
11+
// arrange
12+
const sendSpy = jest.spyOn(S3Client.prototype, 'send');
13+
sendSpy.mockImplementation(() => {});
14+
15+
const testContent = '[{"test":"content"}]';
16+
const testBucketName = 'test-bucket-name';
17+
const testFilePath = '/test/file/path.json';
18+
19+
// act
20+
await writeJsonToFile(testFilePath, testContent, testBucketName);
21+
22+
// assert
23+
expect(sendSpy).toHaveBeenCalledTimes(1);
24+
expect(sendSpy).toHaveBeenCalledWith(
25+
expect.objectContaining({
26+
input: {
27+
Body: testContent,
28+
Bucket: testBucketName,
29+
ContentType: 'application/json',
30+
Key: testFilePath,
31+
},
32+
})
33+
);
34+
});
35+
});
36+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { getAccountId } from '@/src/utils/sts-utils';
2+
import { STSClient } from '@aws-sdk/client-sts';
3+
4+
jest.mock('@aws-sdk/client-sts', () => ({
5+
...jest.requireActual('@aws-sdk/client-sts'),
6+
}));
7+
8+
describe('sts-utils', () => {
9+
describe('getAccountId', () => {
10+
test('should get account ID from current auth context', async () => {
11+
// arrange
12+
const sendSpy = jest.spyOn(STSClient.prototype, 'send');
13+
sendSpy.mockImplementation(() => ({
14+
Account: '000000000000',
15+
}));
16+
17+
// act
18+
const result = await getAccountId();
19+
20+
// assert
21+
expect(result).toBe('000000000000');
22+
});
23+
24+
test('should reject missing account ID', async () => {
25+
// arrange
26+
const sendSpy = jest.spyOn(STSClient.prototype, 'send');
27+
sendSpy.mockImplementation(() => ({}));
28+
29+
// act
30+
let caughtError;
31+
try {
32+
await getAccountId();
33+
} catch (error) {
34+
caughtError = error;
35+
}
36+
37+
// assert
38+
expect(caughtError).toBeTruthy();
39+
expect((caughtError as Error).message).toBe(
40+
'Unable to get account ID from caller'
41+
);
42+
});
43+
});
44+
});

data-migration/user-transfer/src/user-transfer.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,7 @@ import { hideBin } from 'yargs/helpers';
44
import { AttributeValue } from '@aws-sdk/client-dynamodb';
55
import { retrieveTemplates, updateItem } from '@/src/utils/ddb-utils';
66
import { backupData } from '@/src/utils/backup-utils';
7-
8-
export type Parameters = {
9-
sourceOwner: string;
10-
destinationOwner: string;
11-
environment: string;
12-
};
7+
import { Parameters } from '@/src/utils/constants';
138

149
function getParameters(): Parameters {
1510
return yargs(hideBin(process.argv))
@@ -46,6 +41,6 @@ async function updateItems(
4641
export async function performTransfer() {
4742
const parameters = getParameters();
4843
const items = await retrieveTemplates(parameters);
49-
backupData(items, parameters);
44+
await backupData(items, parameters);
5045
await updateItems(items, parameters);
5146
}
Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
11
/* eslint-disable security/detect-non-literal-fs-filename */
22
import { AttributeValue } from '@aws-sdk/client-dynamodb';
3-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
43
import { Parameters } from '@/src/utils/constants';
4+
import { getAccountId } from '@/src/utils/sts-utils';
5+
import { writeJsonToFile } from '@/src/utils/s3-utils';
56

6-
const backupDir = './backups';
7-
8-
function createBackupDir() {
9-
if (!existsSync(backupDir)) {
10-
mkdirSync(backupDir);
11-
}
12-
}
13-
14-
export function backupData(
7+
export async function backupData(
158
items: Record<string, AttributeValue>[],
169
parameters: Parameters
17-
) {
18-
const { environment, sourceOwner, destinationOwner } = parameters;
19-
createBackupDir();
10+
): Promise<void> {
2011
console.log(`Found ${items.length} results`);
21-
const fileName = `usr_tsfr-${new Date().toISOString()}-env-${environment}-src-${sourceOwner}-dest-${destinationOwner}.json`;
22-
writeFileSync(`${backupDir}/${fileName}`, JSON.stringify(items));
23-
console.log(`Backed up data locally to ${fileName}`);
12+
if (items.length <= 0) {
13+
return;
14+
}
15+
16+
const { environment, sourceOwner, destinationOwner } = parameters;
17+
const accountId = await getAccountId();
18+
const bucketName = `nhs-notify-${accountId}-eu-west-2-main-acct-migration-backup`;
19+
20+
const timestamp = new Date().toISOString().replaceAll(/[.:T-]/g, '_');
21+
const filePath = `user-transfer/${environment}/${timestamp}-source-${sourceOwner}-destination-${destinationOwner}.json`;
22+
await writeJsonToFile(filePath, JSON.stringify(items), bucketName);
23+
console.log(`Backed up data to s3://${bucketName}/${filePath}`);
2424
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import {
2+
PutObjectCommand,
3+
PutObjectCommandOutput,
4+
S3Client,
5+
} from '@aws-sdk/client-s3';
6+
7+
const s3Client = new S3Client({
8+
region: process.env.REGION,
9+
retryMode: 'standard',
10+
maxAttempts: 10,
11+
});
12+
13+
export async function writeJsonToFile(
14+
path: string,
15+
content: string,
16+
bucket: string
17+
): Promise<PutObjectCommandOutput> {
18+
return s3Client.send(
19+
new PutObjectCommand({
20+
Bucket: bucket,
21+
Key: path,
22+
Body: content,
23+
ContentType: 'application/json',
24+
})
25+
);
26+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts';
2+
3+
const stsClient = new STSClient({ region: process.env.REGION });
4+
5+
export async function getAccountId(): Promise<string> {
6+
const callerIdentity = await stsClient.send(new GetCallerIdentityCommand());
7+
const accountId = callerIdentity.Account;
8+
if (!accountId) {
9+
throw new Error('Unable to get account ID from caller');
10+
}
11+
return accountId;
12+
}

0 commit comments

Comments
 (0)