diff --git a/.env-example b/.env-example index 3c23c0dd6..da44309d5 100644 --- a/.env-example +++ b/.env-example @@ -26,6 +26,7 @@ export SITE_CREATE_FORM_KEY="" # generate your own access key and secret access key from AWS export AWS_ACCESS_KEY_ID="" export AWS_SECRET_ACCESS_KEY="" +export MOCK_AMPLIFY_CREATE_DOMAIN_CALLS="true" # Required to run end-to-end tests export E2E_TEST_REPO="e2e-test-repo" diff --git a/src/config/config.ts b/src/config/config.ts index be5bd885f..6c7ccaa1a 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -172,6 +172,12 @@ const config = convict({ format: String, default: "", }, + mockAmplifyCreateDomainAssociationCalls: { + doc: "Mock createDomainAssociation calls to Amplify ", + env: "MOCK_AMPLIFY_CREATE_DOMAIN_ASSOCIATION_CALLS", + format: "required-boolean", + default: true, + }, }, sqs: { incomingQueueUrl: { diff --git a/src/routes/formsgSiteLaunch.ts b/src/routes/formsgSiteLaunch.ts index 12a32a07b..60fa3c3c8 100644 --- a/src/routes/formsgSiteLaunch.ts +++ b/src/routes/formsgSiteLaunch.ts @@ -208,7 +208,7 @@ export class FormsgSiteLaunchRouter { ) || [] res.sendStatus(200) // we have received the form and obtained relevant field - await this.handleSiteLaunchResults(formResponses, submissionId) + this.handleSiteLaunchResults(formResponses, submissionId) } sendLaunchError = async ( @@ -301,33 +301,39 @@ export class FormsgSiteLaunchRouter { formResponses: FormResponsesProps[], submissionId: string ) { - const launchResults = await Promise.all( - formResponses.map(this.launchSiteFromForm) - ) - const successResults: DnsRecordsEmailProps[] = [] - launchResults.forEach((launchResult) => { - if (launchResult.isOk()) { - successResults.push(launchResult.value) - } - }) + try { + const launchResults = await Promise.all( + formResponses.map(this.launchSiteFromForm) + ) + const successResults: DnsRecordsEmailProps[] = [] + launchResults.forEach((launchResult) => { + if (launchResult.isOk()) { + successResults.push(launchResult.value) + } + }) - await this.sendDNSRecords(submissionId, successResults) + await this.sendDNSRecords(submissionId, successResults) - const failureResults: LaunchFailureEmailProps[] = [] + const failureResults: LaunchFailureEmailProps[] = [] - launchResults.forEach((launchResult) => { - if (launchResult.isErr() && launchResult.error) { - failureResults.push(launchResult.error) - return - } - if (launchResult.isErr()) { - failureResults.push({ - error: "Unknown error", - }) - } - }) + launchResults.forEach((launchResult) => { + if (launchResult.isErr() && launchResult.error) { + failureResults.push(launchResult.error) + return + } + if (launchResult.isErr()) { + failureResults.push({ + error: "Unknown error", + }) + } + }) - await this.sendLaunchError(submissionId, failureResults) + await this.sendLaunchError(submissionId, failureResults) + } catch (e) { + logger.error( + `Something unexpected went wrong when launching sites from form submission ${submissionId}. Error: ${e}` + ) + } } getRouter() { diff --git a/src/services/identity/LaunchClient.ts b/src/services/identity/LaunchClient.ts index 406dfad65..c8d1101be 100644 --- a/src/services/identity/LaunchClient.ts +++ b/src/services/identity/LaunchClient.ts @@ -1,26 +1,41 @@ import { AmplifyClient, CreateDomainAssociationCommand, - CreateDomainAssociationCommandInput, + CreateDomainAssociationCommandInput as AmplifySDKCreateDomainAssociationCommandInput, CreateDomainAssociationCommandOutput, GetDomainAssociationCommand, - GetDomainAssociationCommandInput, + GetDomainAssociationCommandInput as AmplifySDKGetDomainAssociationCommandInput, GetDomainAssociationCommandOutput, SubDomainSetting, } from "@aws-sdk/client-amplify" -import { options } from "joi" +import { SubDomain } from "aws-sdk/clients/amplify" -import logger from "@root/logger/logger" +import { config } from "@config/config" +// stricter typing to interact with Amplify SDK +type CreateDomainAssociationCommandInput = { + [K in keyof AmplifySDKCreateDomainAssociationCommandInput]: NonNullable< + AmplifySDKCreateDomainAssociationCommandInput[K] + > +} + +type GetDomainAssociationCommandInput = { + [K in keyof AmplifySDKGetDomainAssociationCommandInput]: NonNullable< + AmplifySDKGetDomainAssociationCommandInput[K] + > +} class LaunchClient { private readonly amplifyClient: InstanceType + private readonly mockDomainAssociations: Map + constructor() { const AWS_REGION = "ap-southeast-1" this.amplifyClient = new AmplifyClient({ region: AWS_REGION, }) + this.mockDomainAssociations = new Map() } createDomainAssociationCommandInput = ( @@ -35,8 +50,15 @@ class LaunchClient { sendCreateDomainAssociation = ( input: CreateDomainAssociationCommandInput - ): Promise => - this.amplifyClient.send(new CreateDomainAssociationCommand(input)) + ): Promise => { + if (this.shouldMockAmplifyDomainCalls()) { + return this.mockCreateDomainAssociationOutput(input) + } + const output = this.amplifyClient.send( + new CreateDomainAssociationCommand(input) + ) + return output + } createGetDomainAssociationCommandInput = ( appId: string, @@ -48,8 +70,107 @@ class LaunchClient { sendGetDomainAssociationCommand = ( input: GetDomainAssociationCommandInput - ): Promise => - this.amplifyClient.send(new GetDomainAssociationCommand(input)) + ): Promise => { + if (this.shouldMockAmplifyDomainCalls()) { + // handle mock input + return this.mockGetDomainAssociationOutput(input) + } + return this.amplifyClient.send(new GetDomainAssociationCommand(input)) + } + + /** + * The rate limit for Create Domain Association is 10 per hour. + * We want to limit interference with operations, as such we mock this call during development. + * @returns Mocked output for CreateDomainAssociationCommand + */ + private mockCreateDomainAssociationOutput = ( + input: CreateDomainAssociationCommandInput + ): Promise => { + // We are mocking the response from the Amplify API, so we need to store the input + this.mockDomainAssociations.set(input.domainName, input.subDomainSettings) + const mockResponse: CreateDomainAssociationCommandOutput = { + $metadata: { + httpStatusCode: 200, + }, + domainAssociation: { + autoSubDomainCreationPatterns: [], + autoSubDomainIAMRole: undefined, + certificateVerificationDNSRecord: undefined, + domainAssociationArn: `arn:aws:amplify:ap-southeast-1:11111:apps/${input.appId}/domains/${input.domainName}`, + domainName: input.domainName, + domainStatus: "CREATING", + enableAutoSubDomain: false, + statusReason: undefined, + subDomains: undefined, + }, + } + + const subDomainSettingsList = input.subDomainSettings + if (!subDomainSettingsList || subDomainSettingsList.length === 0) { + return Promise.resolve(mockResponse) + } + + const subDomains: SubDomain[] = this.getSubDomains(subDomainSettingsList) + + // We know that domainAssociation is not undefined, so we can use the non-null assertion operator + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + mockResponse.domainAssociation!.subDomains = subDomains + return Promise.resolve(mockResponse) + } + + private shouldMockAmplifyDomainCalls(): boolean { + return config.get("aws.amplify.mockAmplifyCreateDomainAssociationCalls") + } + + private getSubDomains( + subDomainList: SubDomainSetting[], + isCreated = false + ): SubDomain[] { + const subDomains = subDomainList + .map((subDomain) => subDomain.prefix) + .filter((prefix) => prefix !== undefined && prefix !== null) + .map((subDomainPrefix) => ({ + dnsRecord: `${subDomainPrefix} CNAME ${ + isCreated ? "test.cloudfront.net" : "" + }`, + subDomainSetting: { + branchName: "master", + prefix: subDomainPrefix as string, + }, + verified: false, + })) + return subDomains + } + + private mockGetDomainAssociationOutput( + input: GetDomainAssociationCommandInput + ): Promise { + const isSubDomainCreated = true // this is a `get` call, assume domain has already been created + const subDomainSettings = this.mockDomainAssociations.get(input.domainName) + if (!subDomainSettings) { + throw new Error( + `NotFoundException: Domain association ${input.domainName} not found.` + ) + } + const subDomains = this.getSubDomains(subDomainSettings, isSubDomainCreated) + + const mockResponse: GetDomainAssociationCommandOutput = { + $metadata: { + httpStatusCode: 200, + }, + domainAssociation: { + certificateVerificationDNSRecord: `testcert.${input.domainName}. CNAME testcert.acm-validations.aws.`, + domainAssociationArn: `arn:aws:amplify:ap-southeast-1:11111:apps/${input.appId}/domains/${input.domainName}`, + domainName: input.domainName, + domainStatus: "PENDING_VERIFICATION", + enableAutoSubDomain: false, + subDomains, + statusReason: undefined, + }, + } + + return Promise.resolve(mockResponse) + } } export default LaunchClient diff --git a/src/services/infra/InfraService.ts b/src/services/infra/InfraService.ts index f23ea75c9..588796517 100644 --- a/src/services/infra/InfraService.ts +++ b/src/services/infra/InfraService.ts @@ -2,6 +2,8 @@ import { SubDomainSettings } from "aws-sdk/clients/amplify" import Joi from "joi" import { Err, err, errAsync, Ok, ok, okAsync, Result } from "neverthrow" +import { config } from "@config/config" + import { Site } from "@database/models" import { User } from "@database/models/User" import { @@ -226,19 +228,23 @@ export default class InfraService { ) // Get DNS records from Amplify - /** - * note: we wait for ard 90 sec as there is a time taken - * for amplify to generate the certification manager in the first place - * This is a dirty workaround for now, and will cause issues when we integrate - * this directly within the Isomer CMS. - * todo: push this check into a queue-like system when integrating this with cms - */ - await new Promise((resolve) => setTimeout(resolve, 90000)) - - /** - * todo: add some level of retry logic if get domain association command - * does not contain the DNS redirections info. - */ + const isTestEnv = + config.get("env") === "test" || config.get("env") === "dev" + // since we mock values during development, we don't have to await for the dns records + if (!isTestEnv) { + /** + * note: we wait for ard 90 sec as there is a time taken + * for amplify to generate the certification manager in the first place + * This is a dirty workaround for now, and will cause issues when we integrate + * this directly within the Isomer CMS. + * todo: push this check into a queue-like system when integrating this with cms + */ + await new Promise((resolve) => setTimeout(resolve, 90000)) + /** + * todo: add some level of retry logic if get domain association command + * does not contain the DNS redirections info. + */ + } const dnsInfo = await this.launchesService.getDomainAssociationRecord( primaryDomain, @@ -368,7 +374,7 @@ export default class InfraService { await Promise.all( messages.map(async (message) => { const site = await this.sitesService.getBySiteName(message.repoName) - if (!site || site.isErr()) { + if (site.isErr()) { return } const isSuccess = message.status === SiteLaunchLambdaStatus.SUCCESS