Skip to content

Commit 21fb097

Browse files
bhansell1chris-elliott-nhsd
authored andcommitted
CCM-10429: refactor
1 parent 21be0e0 commit 21fb097

File tree

10 files changed

+745
-39
lines changed

10 files changed

+745
-39
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
migrations/*
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/* eslint-disable security/detect-non-literal-fs-filename */
2+
/* eslint-disable unicorn/prefer-top-level-await */
3+
import fs from 'node:fs';
4+
import path from 'node:path';
5+
import yargs from 'yargs';
6+
import { hideBin } from 'yargs/helpers';
7+
import { MigrationPlan } from './utils/constants';
8+
import { print } from './utils/log';
9+
import { MigrationHandler } from './utils/migration-handler';
10+
11+
const params = yargs(hideBin(process.argv))
12+
.options({
13+
file: {
14+
type: 'string',
15+
demandOption: true,
16+
},
17+
dryRun: {
18+
type: 'boolean',
19+
default: true,
20+
},
21+
})
22+
.parseSync();
23+
24+
function writeLocal(filename: string, data: string) {
25+
fs.writeFile(filename, data, (err) => {
26+
if (err) {
27+
console.log(`Error writing file: ${filename}`, err);
28+
} else {
29+
console.log(`Successfully wrote ${filename}`);
30+
}
31+
});
32+
}
33+
34+
async function migrate() {
35+
const input = JSON.parse(
36+
fs.readFileSync(params.file, 'utf8')
37+
) as MigrationPlan;
38+
39+
const output: MigrationPlan = {
40+
...input,
41+
run: input.run + 1,
42+
};
43+
44+
const migrations = input.migrate.plans.filter((r) => r.status === 'migrate');
45+
46+
print(`Total migrations: ${migrations.length}`);
47+
48+
for (let i = 0; i < migrations.length; i++) {
49+
const migration = migrations[i];
50+
51+
const idx = output.migrate.plans.findIndex(
52+
(r) => r.templateId === migration.templateId
53+
);
54+
55+
if (idx === -1) {
56+
print('Skipping: Unable to locate index for ${templateId}');
57+
continue;
58+
}
59+
60+
print(`Progress: ${i + 1}/${migrations.length}`);
61+
print(`Processing: ${migration.templateId}`);
62+
63+
const result = await MigrationHandler.apply(migration, {
64+
bucketName: input.bucketName,
65+
tableName: input.tableName,
66+
dryRun: params.dryRun,
67+
});
68+
69+
output.migrate.plans[idx] = {
70+
...output.migrate.plans[idx],
71+
stage: result.stage,
72+
status: result.success ? 'success' : 'failed',
73+
reason: JSON.stringify(result.reasons),
74+
};
75+
76+
print(`Result: success - [${result.success}]`);
77+
}
78+
79+
const { dir, name, ext } = path.parse(params.file);
80+
81+
const fileName = `${name}-${params.dryRun ? 'dryrun-' : 'run-'}${output.run}${ext}`;
82+
83+
const data = JSON.stringify(output);
84+
85+
writeLocal(path.join(dir, fileName), data);
86+
}
87+
88+
migrate()
89+
.then(() => console.log('finished'))
90+
.catch((error) => console.error(error));
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/* eslint-disable unicorn/no-process-exit */
2+
/* eslint-disable unicorn/prefer-top-level-await */
3+
import { CognitoRepository } from './utils/cognito-repository';
4+
import { retrieveAllTemplatesV2 } from './utils/ddb-utils';
5+
import {
6+
listItemObjectsWithPaginator,
7+
writeJsonToFile as writeRemote,
8+
} from './utils/s3-utils';
9+
import { MigrationHandler } from './utils/migration-handler';
10+
import yargs from 'yargs';
11+
import { hideBin } from 'yargs/helpers';
12+
import { getAccountId } from './utils/sts-utils';
13+
import { print } from './utils/log';
14+
15+
const params = yargs(hideBin(process.argv))
16+
.options({
17+
environment: {
18+
type: 'string',
19+
demandOption: true,
20+
},
21+
component: {
22+
type: 'string',
23+
default: 'app',
24+
options: ['sbx', 'app'],
25+
},
26+
userPoolId: {
27+
type: 'string',
28+
demandOption: true,
29+
},
30+
iamAccessKeyId: {
31+
type: 'string',
32+
},
33+
iamSecretAccessKey: {
34+
type: 'string',
35+
},
36+
iamSessionToken: {
37+
type: 'string',
38+
},
39+
})
40+
.check((argv) => {
41+
const iamSet =
42+
!!argv.iamAccessKeyId &&
43+
!!argv.iamSecretAccessKey &&
44+
!!argv.iamSessionToken;
45+
46+
if (argv.component === 'app' && !iamSet) {
47+
throw new Error(
48+
'iamAccessKeyId, iamSecretAccessKey, iamSessionToken - must be set when using component: app'
49+
);
50+
}
51+
return true;
52+
})
53+
.parseSync();
54+
55+
const cognitoCrawler = new CognitoRepository(
56+
params.userPoolId,
57+
params.component === 'app'
58+
? {
59+
accessKeyId: params.iamAccessKeyId!,
60+
secretAccessKey: params.iamSecretAccessKey!,
61+
sessionToken: params.iamSessionToken!,
62+
}
63+
: undefined
64+
);
65+
66+
async function getConfig() {
67+
const accountId = await getAccountId();
68+
69+
return {
70+
accountId,
71+
templateTableName: `nhs-notify-${params.environment}-${params.component}-api-templates`,
72+
templatesS3InternalBucketName: `nhs-notify-${accountId}-eu-west-2-${params.environment}-${params.component}-internal`,
73+
templatesS3BackupBucketName: `nhs-notify-${accountId}-eu-west-2-main-acct-migration-backup`,
74+
};
75+
}
76+
77+
async function plan() {
78+
const {
79+
accountId,
80+
templateTableName,
81+
templatesS3InternalBucketName,
82+
templatesS3BackupBucketName,
83+
} = await getConfig();
84+
85+
const users = await cognitoCrawler.getAllUsers();
86+
87+
const templates = await retrieveAllTemplatesV2(templateTableName);
88+
89+
const files = await listItemObjectsWithPaginator(
90+
templatesS3InternalBucketName
91+
);
92+
93+
const transferPlan = await MigrationHandler.plan(
94+
users,
95+
{
96+
tableName: templateTableName,
97+
templates,
98+
},
99+
{
100+
bucketName: templatesS3InternalBucketName,
101+
files,
102+
}
103+
);
104+
105+
const fileName = `transfer-plan-${accountId}-${params.environment}-${params.component}-${Date.now()}.json`;
106+
107+
const data = JSON.stringify(transferPlan);
108+
109+
const filePath = `ownership-transfer/${params.environment}/${fileName}`;
110+
111+
await writeRemote(filePath, data, templatesS3BackupBucketName);
112+
113+
print(`Plan written to s3://${templatesS3InternalBucketName}/${filePath}`);
114+
}
115+
116+
plan().catch((error) => {
117+
console.error(error);
118+
process.exit(1);
119+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/* eslint-disable sonarjs/no-commented-code */
2+
import {
3+
CognitoIdentityProviderClient,
4+
ListUsersCommand,
5+
ListUsersCommandInput,
6+
AdminListGroupsForUserCommand,
7+
UserType,
8+
GroupType,
9+
} from '@aws-sdk/client-cognito-identity-provider';
10+
import { print } from './log';
11+
12+
export type UserData = {
13+
username: string;
14+
/*
15+
* extracted from the user's group starting with 'client:'
16+
*/
17+
clientId: string;
18+
/*
19+
* extracted from the user's 'sub' attribute
20+
*/
21+
userId: string;
22+
};
23+
24+
export class CognitoRepository {
25+
private client: CognitoIdentityProviderClient;
26+
private userPoolId: string;
27+
28+
constructor(
29+
userPoolId: string,
30+
credentials?: {
31+
accessKeyId: string;
32+
secretAccessKey: string;
33+
sessionToken: string;
34+
}
35+
) {
36+
this.userPoolId = userPoolId;
37+
38+
this.client = new CognitoIdentityProviderClient({
39+
region: 'eu-west-2',
40+
credentials,
41+
});
42+
}
43+
44+
async getAllUsers(): Promise<UserData[]> {
45+
const users: UserData[] = [];
46+
47+
let paginationToken: string | undefined;
48+
49+
try {
50+
do {
51+
const params: ListUsersCommandInput = {
52+
UserPoolId: this.userPoolId,
53+
Limit: 60, // Max allowed by AWS
54+
PaginationToken: paginationToken,
55+
};
56+
57+
const command = new ListUsersCommand(params);
58+
59+
const response = await this.client.send(command);
60+
61+
if (response.Users) {
62+
const userBatch = await Promise.all(
63+
response.Users.map((user) => this.processUser(user))
64+
);
65+
66+
users.push(...userBatch.filter((r) => r !== undefined));
67+
}
68+
69+
paginationToken = response.PaginationToken;
70+
} while (paginationToken);
71+
72+
return users;
73+
} catch (error) {
74+
print('Failed: fetching users');
75+
throw error;
76+
}
77+
}
78+
79+
private async processUser(user: UserType): Promise<UserData | undefined> {
80+
const username = user.Username!;
81+
82+
const userId = user.Attributes?.find(({ Name }) => Name === 'sub')?.Value;
83+
84+
const clientId = await this.getClientIdFromGroups(username);
85+
86+
if (!clientId || !userId) {
87+
print(`Ignoring User: ${username}. No client or sub assigned`);
88+
return;
89+
}
90+
91+
return {
92+
username,
93+
clientId,
94+
userId,
95+
};
96+
}
97+
98+
private async getClientIdFromGroups(
99+
username: string
100+
): Promise<string | undefined> {
101+
try {
102+
const command = new AdminListGroupsForUserCommand({
103+
UserPoolId: this.userPoolId,
104+
Username: username,
105+
});
106+
107+
const response = await this.client.send(command);
108+
109+
if (!response.Groups) {
110+
return;
111+
}
112+
113+
const clientGroup = response.Groups.find((group: GroupType) =>
114+
group.GroupName?.startsWith('client:')
115+
);
116+
117+
return clientGroup?.GroupName?.split(':')[1];
118+
} catch (error) {
119+
print(`Failed: to get groups for user ${username}`);
120+
throw error;
121+
}
122+
}
123+
}

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,56 @@ export type Parameters = {
88
sessionToken: string;
99
flag?: string;
1010
};
11+
12+
export type Template = {
13+
id: string;
14+
owner: string;
15+
};
16+
17+
type ToFrom = {
18+
from: string;
19+
to: string;
20+
};
21+
22+
export type MigrationStage =
23+
| 's3:copy'
24+
| 'ddb:transfer'
25+
| 's3:delete'
26+
| 'initial'
27+
| 'finished';
28+
29+
export type MigrationStatus = 'success' | 'failed' | 'migrate';
30+
31+
export type MigrationPlanItem = {
32+
templateId: string;
33+
status: MigrationStatus;
34+
stage: MigrationStage;
35+
reason?: string;
36+
ddb: {
37+
owner: ToFrom;
38+
};
39+
s3: {
40+
files: ToFrom[];
41+
};
42+
};
43+
44+
export type MigrationPlan = {
45+
total: number;
46+
tableName: string;
47+
bucketName: string;
48+
run: number;
49+
migrate: {
50+
count: number;
51+
plans: MigrationPlanItem[];
52+
};
53+
orphaned: {
54+
count: number;
55+
templateIds: string[];
56+
};
57+
};
58+
59+
export type MigrationPlanItemResult = {
60+
success: boolean;
61+
stage: MigrationStage;
62+
reasons?: string[];
63+
};

0 commit comments

Comments
 (0)