diff --git a/.bashrc b/.bashrc new file mode 100644 index 000000000..e69de29bb diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..0bbc98eda --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# dependencies +node_modules + +# build outputs +/dist +/apps/*/dist + +# test/coverage +/coverage + +# local env files (keep example.env) +.env +.env.* +!.env.example +!example.env + +# logs +*.log + +# VCS / editor +.git +.gitignore +.vscode + +# OS +.DS_Store +Thumbs.db diff --git a/.gitignore b/.gitignore index 2706eea85..c505be3cf 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,5 @@ Thumbs.db .vscode .postman -infrastructure/params/*.json \ No newline at end of file +infrastructure/params/*.json +.jest-tmp/ diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile new file mode 100644 index 000000000..96db740a7 --- /dev/null +++ b/apps/backend/Dockerfile @@ -0,0 +1,38 @@ +# syntax=docker/dockerfile:1 + +FROM node:20-bookworm-slim AS builder + +WORKDIR /app + +RUN corepack enable && corepack prepare yarn@1.22.22 --activate + +COPY package.json yarn.lock nx.json tsconfig.base.json jest.preset.js ./ + +RUN yarn install --frozen-lockfile + +COPY . . + +RUN yarn nx build backend --configuration=production + + +FROM node:20-bookworm-slim AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +RUN corepack enable && corepack prepare yarn@1.22.22 --activate + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +COPY package.json yarn.lock ./ + +RUN yarn install --frozen-lockfile --production=true && yarn cache clean + +COPY --from=builder /app/dist/apps/backend ./dist/apps/backend + +EXPOSE 3000 + +CMD ["sh", "-c", "curl -fsSL -o /app/global-bundle.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem && exec node dist/apps/backend/main.js"] diff --git a/apps/backend/src/app.controller.ts b/apps/backend/src/app.controller.ts index bb8be8edb..b0e95c1db 100644 --- a/apps/backend/src/app.controller.ts +++ b/apps/backend/src/app.controller.ts @@ -13,4 +13,9 @@ export class AppController { getData() { return this.appService.getData(); } + + @Get('health') + getHealth() { + return { status: 'ok' }; + } } diff --git a/apps/backend/src/data-source.ts b/apps/backend/src/data-source.ts index 3b2df08d3..b5727c480 100644 --- a/apps/backend/src/data-source.ts +++ b/apps/backend/src/data-source.ts @@ -1,4 +1,5 @@ import { DataSource } from 'typeorm'; +import fs from 'fs'; import { AdminInfo } from './admin-info/admin-info.entity'; import { User } from './users/user.entity'; import { PluralNamingStrategy } from './strategies/plural-naming.strategy'; @@ -10,6 +11,9 @@ import { CandidateInfo } from './candidate-info/candidate-info.entity'; dotenv.config(); +const sslCaPath = process.env.NX_DB_SSL_CA_PATH; +const sslCa = sslCaPath ? fs.readFileSync(sslCaPath, 'utf8') : undefined; + const AppDataSource = new DataSource({ type: 'postgres', host: process.env.NX_DB_HOST, @@ -17,6 +21,12 @@ const AppDataSource = new DataSource({ username: process.env.NX_DB_USERNAME, password: process.env.NX_DB_PASSWORD, database: process.env.NX_DB_DATABASE, + ssl: sslCa + ? { + rejectUnauthorized: true, + ca: sslCa, + } + : undefined, entities: [ Application, CandidateInfo, diff --git a/apps/backend/src/util/aws-exports.spec.ts b/apps/backend/src/util/aws-exports.spec.ts index 33a3dab45..649390b36 100644 --- a/apps/backend/src/util/aws-exports.spec.ts +++ b/apps/backend/src/util/aws-exports.spec.ts @@ -1,28 +1,25 @@ type AwsExports = { AWSConfig: { - accessKeyId?: string; - secretAccessKey?: string; bucketName?: string; + region?: string; }; CognitoAuthConfig: { userPoolId?: string; clientId?: string; - region?: string; - clientSecret?: string; }; }; const ORIGINAL_ENV = { ...process.env }; const ENV_KEYS = [ - 'AWS_ACCESS_KEY_ID', - 'NX_AWS_ACCESS_KEY', - 'AWS_SECRET_ACCESS_KEY', - 'NX_AWS_SECRET_ACCESS_KEY', + 'BHCHP_AWS_BUCKET_NAME', 'AWS_BUCKET_NAME', + 'BHCHP_AWS_REGION', + 'AWS_REGION', + 'BHCHP_AWS_SES_SENDER_EMAIL', + 'AWS_SES_SENDER_EMAIL', 'COGNITO_APP_CLIENT_ID', 'VITE_COGNITO_APP_CLIENT_ID', - 'COGNITO_CLIENT_SECRET', 'COGNITO_USER_POOL_ID', 'VITE_COGNITO_USER_POOL_ID', 'COGNITO_REGION', @@ -52,80 +49,63 @@ describe('aws-exports', () => { }); it('loads config when required primary env vars are present', async () => { - process.env.AWS_ACCESS_KEY_ID = 'aws-key'; - process.env.AWS_SECRET_ACCESS_KEY = 'aws-secret'; - process.env.AWS_BUCKET_NAME = 'app-bucket'; + process.env.BHCHP_AWS_BUCKET_NAME = 'app-bucket'; + process.env.BHCHP_AWS_REGION = 'us-west-2'; process.env.COGNITO_APP_CLIENT_ID = 'cognito-client'; - process.env.COGNITO_CLIENT_SECRET = 'cognito-secret'; process.env.COGNITO_USER_POOL_ID = 'pool-id'; - process.env.COGNITO_REGION = 'us-west-2'; const config = await loadAwsExports(); expect(config.AWSConfig).toEqual({ - accessKeyId: 'aws-key', - secretAccessKey: 'aws-secret', region: 'us-west-2', bucketName: 'app-bucket', }); expect(config.CognitoAuthConfig).toEqual({ userPoolId: 'pool-id', clientId: 'cognito-client', - clientSecret: 'cognito-secret', }); }); it('uses NX and VITE fallback env vars when primary vars are absent', async () => { - process.env.NX_AWS_ACCESS_KEY = 'nx-aws-key'; - process.env.NX_AWS_SECRET_ACCESS_KEY = 'nx-aws-secret'; - process.env.AWS_BUCKET_NAME = 'fallback-bucket'; + process.env.BHCHP_AWS_BUCKET_NAME = 'fallback-bucket'; process.env.VITE_COGNITO_APP_CLIENT_ID = 'vite-client'; - process.env.COGNITO_CLIENT_SECRET = 'cognito-secret'; process.env.VITE_COGNITO_USER_POOL_ID = 'vite-pool'; process.env.VITE_COGNITO_REGION = 'eu-west-1'; const config = await loadAwsExports(); expect(config.AWSConfig).toEqual({ - accessKeyId: 'nx-aws-key', - secretAccessKey: 'nx-aws-secret', region: 'eu-west-1', bucketName: 'fallback-bucket', }); expect(config.CognitoAuthConfig).toEqual({ userPoolId: 'vite-pool', clientId: 'vite-client', - clientSecret: 'cognito-secret', }); }); it('throws when required AWS env vars are missing', async () => { await expect(loadAwsExports()).rejects.toThrow( - 'The following environmental variables are missing:AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_BUCKET_NAME,COGNITO_REGION', + 'The following environmental variables are missing:BHCHP_AWS_BUCKET_NAME,AWS_REGION', ); }); - it('throws when Cognito region vars are missing', async () => { - process.env.AWS_ACCESS_KEY_ID = 'aws-key'; - process.env.AWS_SECRET_ACCESS_KEY = 'aws-secret'; - process.env.AWS_BUCKET_NAME = 'app-bucket'; + it('throws when AWS and Cognito region vars are missing', async () => { + process.env.BHCHP_AWS_BUCKET_NAME = 'app-bucket'; process.env.COGNITO_APP_CLIENT_ID = 'cognito-client'; - process.env.COGNITO_CLIENT_SECRET = 'cognito-secret'; process.env.COGNITO_USER_POOL_ID = 'pool-id'; await expect(loadAwsExports()).rejects.toThrow( - 'The following environmental variables are missing:COGNITO_REGION', + 'The following environmental variables are missing:AWS_REGION', ); }); - it('throws when Cognito app client and client secret are missing', async () => { - process.env.AWS_ACCESS_KEY_ID = 'aws-key'; - process.env.AWS_SECRET_ACCESS_KEY = 'aws-secret'; - process.env.AWS_BUCKET_NAME = 'app-bucket'; - process.env.COGNITO_REGION = 'us-west-2'; + it('throws when Cognito app client is missing', async () => { + process.env.BHCHP_AWS_BUCKET_NAME = 'app-bucket'; + process.env.AWS_REGION = 'us-west-2'; await expect(loadAwsExports()).rejects.toThrow( - 'The following environmental variables are missing:COGNITO_APP_CLIENT_ID,COGNITO_CLIENT_SECRET', + 'The following environmental variables are missing:COGNITO_APP_CLIENT_ID', ); }); }); diff --git a/apps/backend/src/util/aws-exports.ts b/apps/backend/src/util/aws-exports.ts index ffe40307d..7de1a0277 100644 --- a/apps/backend/src/util/aws-exports.ts +++ b/apps/backend/src/util/aws-exports.ts @@ -4,20 +4,16 @@ */ function checkAWSSecrets(): void { const missingVars = []; - if (!process.env.AWS_ACCESS_KEY_ID && !process.env.NX_AWS_ACCESS_KEY) { - missingVars.push('AWS_ACCESS_KEY_ID'); + if (!process.env.BHCHP_AWS_BUCKET_NAME && !process.env.AWS_BUCKET_NAME) { + missingVars.push('BHCHP_AWS_BUCKET_NAME'); } if ( - !process.env.AWS_SECRET_ACCESS_KEY && - !process.env.NX_AWS_SECRET_ACCESS_KEY + !process.env.BHCHP_AWS_REGION && + !process.env.AWS_REGION && + !process.env.COGNITO_REGION && + !process.env.VITE_COGNITO_REGION ) { - missingVars.push('AWS_SECRET_ACCESS_KEY'); - } - if (!process.env.AWS_BUCKET_NAME) { - missingVars.push('AWS_BUCKET_NAME'); - } - if (!process.env.COGNITO_REGION && !process.env.VITE_COGNITO_REGION) { - missingVars.push('COGNITO_REGION'); + missingVars.push('AWS_REGION'); } if (missingVars.length > 0) { throw new Error( @@ -30,11 +26,10 @@ function checkAWSSecrets(): void { checkAWSSecrets(); const AWSConfig = { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.NX_AWS_ACCESS_KEY, - secretAccessKey: - process.env.AWS_SECRET_ACCESS_KEY || process.env.NX_AWS_SECRET_ACCESS_KEY, - bucketName: process.env.AWS_BUCKET_NAME, + bucketName: process.env.BHCHP_AWS_BUCKET_NAME || process.env.AWS_BUCKET_NAME, region: + process.env.BHCHP_AWS_REGION || + process.env.AWS_REGION || process.env.COGNITO_REGION || process.env.VITE_COGNITO_REGION || 'us-east-2', @@ -46,24 +41,12 @@ const AWSConfig = { */ function checkAuthSecrets(): void { const missingVars = []; - if (!process.env.AWS_ACCESS_KEY_ID && !process.env.NX_AWS_ACCESS_KEY) { - missingVars.push('AWS_ACCESS_KEY_ID'); - } - if ( - !process.env.AWS_SECRET_ACCESS_KEY && - !process.env.NX_AWS_SECRET_ACCESS_KEY - ) { - missingVars.push('AWS_SECRET_ACCESS_KEY'); - } if ( !process.env.COGNITO_APP_CLIENT_ID && !process.env.VITE_COGNITO_APP_CLIENT_ID ) { missingVars.push('COGNITO_APP_CLIENT_ID'); } - if (!process.env.COGNITO_CLIENT_SECRET) { - missingVars.push('COGNITO_CLIENT_SECRET'); - } if (missingVars.length > 0) { throw new Error( 'The following environmental variables are missing:' + @@ -79,7 +62,6 @@ const CognitoAuthConfig = { process.env.COGNITO_USER_POOL_ID || process.env.VITE_COGNITO_USER_POOL_ID, clientId: process.env.COGNITO_APP_CLIENT_ID || process.env.VITE_COGNITO_APP_CLIENT_ID, - clientSecret: process.env.COGNITO_CLIENT_SECRET, }; const PublicFrontendUrl = diff --git a/apps/backend/src/util/aws-s3/aws-s3.service.spec.ts b/apps/backend/src/util/aws-s3/aws-s3.service.spec.ts index e9e202d96..ac2f17643 100644 --- a/apps/backend/src/util/aws-s3/aws-s3.service.spec.ts +++ b/apps/backend/src/util/aws-s3/aws-s3.service.spec.ts @@ -21,8 +21,6 @@ jest.mock('../aws-exports', () => ({ __esModule: true, default: { AWSConfig: { - accessKeyId: 'test-access-key', - secretAccessKey: 'test-secret-key', region: 'us-east-2', bucketName: 'bucket', }, @@ -107,7 +105,8 @@ describe('AWSS3Service', () => { // cleanup uploaded object(s) from S3 using helper try { await deleteObjects({ - bucketName: process.env.AWS_BUCKET_NAME, + bucketName: + process.env.BHCHP_AWS_BUCKET_NAME || process.env.AWS_BUCKET_NAME, keys: [uploadedFileName], }); } catch (cleanupErr) { @@ -120,7 +119,8 @@ describe('AWSS3Service', () => { try { await getObjectFromS3({ - bucketName: process.env.AWS_BUCKET_NAME, + bucketName: + process.env.BHCHP_AWS_BUCKET_NAME || process.env.AWS_BUCKET_NAME, key: uploadedFileName, }); } catch (err) { @@ -151,11 +151,7 @@ describe('AWSS3Service', () => { */ const deleteObjects = async ({ bucketName, keys }) => { const client = new S3Client({ - region: process.env.AWS_REGION, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, - }, + region: process.env.BHCHP_AWS_REGION || process.env.AWS_REGION, }); try { diff --git a/apps/backend/src/util/aws-s3/aws-s3.service.ts b/apps/backend/src/util/aws-s3/aws-s3.service.ts index d661df2e2..e4f2c9cf9 100644 --- a/apps/backend/src/util/aws-s3/aws-s3.service.ts +++ b/apps/backend/src/util/aws-s3/aws-s3.service.ts @@ -26,15 +26,8 @@ export class AWSS3Service { } constructor() { - const accessKeyId = envVars.AWSConfig.accessKeyId ?? ''; - const secretAccessKey = envVars.AWSConfig.secretAccessKey ?? ''; - this.client = new S3Client({ region: this.region, - credentials: { - accessKeyId, - secretAccessKey, - }, }); } diff --git a/apps/backend/src/util/email/amazon-ses-client.factory.ts b/apps/backend/src/util/email/amazon-ses-client.factory.ts index 0222f3b63..8d112c60f 100644 --- a/apps/backend/src/util/email/amazon-ses-client.factory.ts +++ b/apps/backend/src/util/email/amazon-ses-client.factory.ts @@ -14,21 +14,21 @@ export const amazonSESClientFactory: Provider = { provide: AMAZON_SES_CLIENT, useFactory: () => { assert( - process.env.AWS_ACCESS_KEY_ID !== undefined, - 'AWS_ACCESS_KEY_ID is not defined', + process.env.BHCHP_AWS_REGION !== undefined || + process.env.AWS_REGION !== undefined || + process.env.COGNITO_REGION !== undefined || + process.env.VITE_COGNITO_REGION !== undefined, + 'AWS region is not defined', ); - assert( - process.env.AWS_SECRET_ACCESS_KEY !== undefined, - 'AWS_SECRET_ACCESS_KEY is not defined', - ); - assert(process.env.AWS_REGION !== undefined, 'AWS_REGION is not defined'); + + const region = + process.env.BHCHP_AWS_REGION || + process.env.AWS_REGION || + process.env.COGNITO_REGION || + process.env.VITE_COGNITO_REGION; return new SESClient({ - region: process.env.AWS_REGION, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, - }, + region, }); }, }; diff --git a/apps/backend/src/util/email/amazon-ses.wrapper.ts b/apps/backend/src/util/email/amazon-ses.wrapper.ts index 5287b9090..62e5db3bb 100644 --- a/apps/backend/src/util/email/amazon-ses.wrapper.ts +++ b/apps/backend/src/util/email/amazon-ses.wrapper.ts @@ -59,7 +59,9 @@ export class AmazonSESWrapper { attachments?: EmailAttachment[], ) { const mailOptions: Mail.Options = { - from: process.env.AWS_SES_SENDER_EMAIL, + from: + process.env.BHCHP_AWS_SES_SENDER_EMAIL || + process.env.AWS_SES_SENDER_EMAIL, to: recipientEmails, subject: subject, html: bodyHtml, @@ -77,7 +79,9 @@ export class AmazonSESWrapper { const params: SendRawEmailCommandInput = { Destinations: recipientEmails, - Source: process.env.AWS_SES_SENDER_EMAIL, + Source: + process.env.BHCHP_AWS_SES_SENDER_EMAIL || + process.env.AWS_SES_SENDER_EMAIL, RawMessage: { Data: messageData }, }; diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile new file mode 100644 index 000000000..fb1dc1387 --- /dev/null +++ b/apps/frontend/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1 + +FROM node:20-bookworm-slim AS builder + +WORKDIR /app + +RUN corepack enable && corepack prepare yarn@1.22.22 --activate + +COPY package.json yarn.lock nx.json tsconfig.base.json jest.preset.js ./ + +RUN yarn install --frozen-lockfile + +COPY . . + +RUN yarn nx build frontend --configuration=production + + +FROM nginx:1.27-alpine AS runner + +# SPA fallback for React Router +RUN printf 'server {\n listen 80;\n server_name _;\n root /usr/share/nginx/html;\n index index.html;\n location / {\n try_files \\$uri \\$uri/ /index.html;\n }\n}\n' > /etc/nginx/conf.d/default.conf + +COPY --from=builder /app/dist/apps/frontend /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 472539126..696866d8f 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -20,7 +20,7 @@ import { } from './types'; const defaultBaseUrl = - import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000'; + import.meta.env.VITE_API_BASE_URL ?? 'http://3.14.133.216'; export class ApiClient { private axiosInstance: AxiosInstance; diff --git a/example.env b/example.env index 06a3f61fd..fa4942b21 100644 --- a/example.env +++ b/example.env @@ -5,6 +5,7 @@ NX_DB_PASSWORD= NX_DB_DATABASE=bhchp AWS_BUCKET_NAME= AWS_REGION=us-east-2 +# Optional for local-only static credentials. ECS uses the task role instead. AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_SES_SENDER_EMAIL= @@ -19,4 +20,4 @@ VITE_COGNITO_REGION=us-east-2 #Frontend Variable that uses app client with no secret requirement VITE_COGNITO_APP_CLIENT_ID= VITE_AWS_REGION=us-east-2 -PUBLIC_FRONTEND_URL=https:// \ No newline at end of file +PUBLIC_FRONTEND_URL=https:// diff --git a/infrastructure/01-s3-bucket.yml b/infrastructure/01-s3-bucket.yml new file mode 100644 index 000000000..f30d6886c --- /dev/null +++ b/infrastructure/01-s3-bucket.yml @@ -0,0 +1,43 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Recreated S3 bucket from existing bhchp-bucket configuration + +Parameters: + Name: + Type: String + Default: bhchp-bucket + +Resources: + BhchpBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref Name + VersioningConfiguration: + Status: Suspended + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + PublicAccessBlockConfiguration: + BlockPublicAcls: false + IgnorePublicAcls: false + BlockPublicPolicy: false + RestrictPublicBuckets: false + OwnershipControls: + Rules: + - ObjectOwnership: BucketOwnerEnforced + BhchpBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: !Ref BhchpBucket + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: PublicReadGetObject + Effect: Allow + Principal: "*" + Action: s3:GetObject + Resource: !Sub "arn:aws:s3:::${BhchpBucket}/*" + +Outputs: + BucketName: + Value: !Ref BhchpBucket \ No newline at end of file diff --git a/infrastructure/02-ecr.yml b/infrastructure/02-ecr.yml new file mode 100644 index 000000000..bc94b7f37 --- /dev/null +++ b/infrastructure/02-ecr.yml @@ -0,0 +1,43 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: ECR repository for bhchp-backend with lifecycle policy + +Parameters: + Name: + Type: String + Default: bhchp-bucket + +Resources: + BhchpBackendRepository: + Type: AWS::ECR::Repository + Properties: + BucketName: !Ref Name + RepositoryName: bhchp-backend + ImageTagMutability: MUTABLE + ImageScanningConfiguration: + ScanOnPush: false + EncryptionConfiguration: + EncryptionType: AES256 + LifecyclePolicy: + LifecyclePolicyText: | + { + "rules": [ + { + "rulePriority": 1, + "description": "Keep only last 3 images", + "selection": { + "tagStatus": "any", + "countType": "imageCountMoreThan", + "countNumber": 3 + }, + "action": { + "type": "expire" + } + } + ] + } + +Outputs: + RepositoryUri: + Value: !GetAtt BhchpBackendRepository.RepositoryUri + RepositoryName: + Value: !Ref BhchpBackendRepository \ No newline at end of file diff --git a/infrastructure/03-cognito.yml b/infrastructure/03-cognito.yml new file mode 100644 index 000000000..ab8c4bd4f --- /dev/null +++ b/infrastructure/03-cognito.yml @@ -0,0 +1,79 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Cognito user pool and clients for proj-bhchp + +Parameters: + CognitoRegion: + Type: String + Default: us-east-2 + UserPoolName: + Type: String + Default: bhchp-user-pool + FrontendClientName: + Type: String + Default: bhchp-frontend-no-secret + FrontendRefreshTokenValidityDays: + Type: Number + Default: 30 + MinValue: 1 + MaxValue: 3650 + +Resources: + UserPool: + Type: AWS::Cognito::UserPool + Properties: + UserPoolName: !Ref UserPoolName + AccountRecoverySetting: + RecoveryMechanisms: + - Name: verified_email + Priority: 1 + - Name: verified_phone_number + Priority: 2 + UsernameAttributes: + - email + UsernameConfiguration: + CaseSensitive: false + AutoVerifiedAttributes: + - email + AdminCreateUserConfig: + AllowAdminCreateUserOnly: false + MfaConfiguration: "OFF" + EmailConfiguration: + EmailSendingAccount: COGNITO_DEFAULT + Policies: + PasswordPolicy: + MinimumLength: 8 + RequireLowercase: true + RequireUppercase: true + RequireNumbers: true + RequireSymbols: true + TemporaryPasswordValidityDays: 7 + VerificationMessageTemplate: + DefaultEmailOption: CONFIRM_WITH_CODE + + FrontendUserPoolClient: + Type: AWS::Cognito::UserPoolClient + Properties: + ClientName: !Ref FrontendClientName + UserPoolId: !Ref UserPool + GenerateSecret: false + SupportedIdentityProviders: + - COGNITO + ExplicitAuthFlows: + - ALLOW_REFRESH_TOKEN_AUTH + - ALLOW_USER_PASSWORD_AUTH + - ALLOW_USER_SRP_AUTH + AccessTokenValidity: 60 + IdTokenValidity: 60 + RefreshTokenValidity: !Ref FrontendRefreshTokenValidityDays + TokenValidityUnits: + AccessToken: minutes + IdToken: minutes + RefreshToken: days + +Outputs: + CognitoUserPoolId: + Value: !Ref UserPool + ViteCognitoAppClientId: + Value: !Ref FrontendUserPoolClient + CognitoRegion: + Value: !Ref CognitoRegion diff --git a/infrastructure/04-rds-postgres.yml b/infrastructure/04-rds-postgres.yml new file mode 100644 index 000000000..4026ae926 --- /dev/null +++ b/infrastructure/04-rds-postgres.yml @@ -0,0 +1,119 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Cheapest practical private RDS PostgreSQL (single-AZ, gp3, dev/test) + +Parameters: + VpcId: + Type: AWS::EC2::VPC::Id + Description: VPC for the RDS instance + SubnetIds: + Type: List + Description: Private subnets in at least two AZs + AllowedCidr: + Type: String + Default: "" + Description: Optional CIDR allowed to reach Postgres (leave empty for none) + ECSSecurityGroupId: + Type: String + Default: "" + Description: Optional ECS service security group to allow + + DBInstanceIdentifier: + Type: String + Default: bhchp-postgres + DBName: + Type: String + Default: bhchp + DBUsername: + Type: String + Default: postgres + DBPassword: + Type: String + NoEcho: true + + DBInstanceClass: + Type: String + Default: db.t4g.micro + AllocatedStorage: + Type: Number + Default: 20 + MaxAllocatedStorage: + Type: Number + Default: 0 + Description: 0 disables storage autoscaling + BackupRetentionDays: + Type: Number + Default: 1 + PubliclyAccessible: + Type: String + AllowedValues: ["true", "false"] + Default: "false" + DeletionProtection: + Type: String + AllowedValues: ["true", "false"] + Default: "false" + +Conditions: + UseMaxStorage: !Not [!Equals [!Ref MaxAllocatedStorage, 0]] + AllowEcsSg: !Not [!Equals [!Ref ECSSecurityGroupId, ""]] + AllowCidr: !Not [!Equals [!Ref AllowedCidr, ""]] + +Resources: + RdsSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Allow Postgres access + VpcId: !Ref VpcId + + RdsSecurityGroupIngressFromCidr: + Type: AWS::EC2::SecurityGroupIngress + Condition: AllowCidr + Properties: + GroupId: !Ref RdsSecurityGroup + IpProtocol: tcp + FromPort: 5432 + ToPort: 5432 + CidrIp: !Ref AllowedCidr + + RdsSecurityGroupIngressFromEcs: + Type: AWS::EC2::SecurityGroupIngress + Condition: AllowEcsSg + Properties: + GroupId: !Ref RdsSecurityGroup + IpProtocol: tcp + FromPort: 5432 + ToPort: 5432 + SourceSecurityGroupId: !Ref ECSSecurityGroupId + + RdsSubnetGroup: + Type: AWS::RDS::DBSubnetGroup + Properties: + DBSubnetGroupDescription: RDS subnets + SubnetIds: !Ref SubnetIds + + RdsInstance: + Type: AWS::RDS::DBInstance + Properties: + DBInstanceIdentifier: !Ref DBInstanceIdentifier + DBName: !Ref DBName + Engine: postgres + DBInstanceClass: !Ref DBInstanceClass + AllocatedStorage: !Ref AllocatedStorage + MaxAllocatedStorage: !If [UseMaxStorage, !Ref MaxAllocatedStorage, !Ref "AWS::NoValue"] + StorageType: gp3 + MasterUsername: !Ref DBUsername + MasterUserPassword: !Ref DBPassword + BackupRetentionPeriod: !Ref BackupRetentionDays + MultiAZ: false + PubliclyAccessible: !Ref PubliclyAccessible + DeletionProtection: !Ref DeletionProtection + VPCSecurityGroups: + - !Ref RdsSecurityGroup + DBSubnetGroupName: !Ref RdsSubnetGroup + +Outputs: + DbEndpoint: + Value: !GetAtt RdsInstance.Endpoint.Address + DbPort: + Value: !GetAtt RdsInstance.Endpoint.Port + DbSecurityGroupId: + Value: !Ref RdsSecurityGroup diff --git a/infrastructure/05-ecs-fargate.yml b/infrastructure/05-ecs-fargate.yml new file mode 100644 index 000000000..758f3bf0e --- /dev/null +++ b/infrastructure/05-ecs-fargate.yml @@ -0,0 +1,313 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Cheapest practical ECS Fargate service for the backend + +Parameters: + VpcId: + Type: AWS::EC2::VPC::Id + Description: VPC for the ECS service + SubnetIds: + Type: List + Description: Subnets for the ECS service + AssignPublicIp: + Type: String + AllowedValues: ["ENABLED", "DISABLED"] + Default: "ENABLED" + AllowedCidr: + Type: String + Default: 0.0.0.0/0 + Description: CIDR allowed to reach the service port + HostedZoneId: + Type: String + Description: Route53 hosted zone ID for the domain + DomainName: + Type: String + Description: Domain name for the ALB (e.g. api.example.com) + HealthCheckPath: + Type: String + Default: /api/health + Description: HTTP path used by the ALB target group health check + + ClusterName: + Type: String + Default: bhchp-ecs + ServiceName: + Type: String + Default: bhchp-backend + + ContainerImage: + Type: String + Description: ECR or public image URI + ContainerPort: + Type: Number + Default: 3000 + + Cpu: + Type: String + Default: "256" + Memory: + Type: String + Default: "512" + + DbHost: + Type: String + DbPort: + Type: String + Default: "5432" + DbName: + Type: String + DbUsername: + Type: String + DbPassword: + Type: String + NoEcho: true + + BhchpAwsBucketName: + Type: String + BhchpAwsSesSenderEmail: + Type: String + BhchpAwsSesIdentityArn: + Type: String + Description: Verified SES identity ARN allowed for SendRawEmail + CognitoRegion: + Type: String + CognitoUserPoolId: + Type: String + CognitoAppClientId: + Type: String + ViteCognitoAppClientId: + Type: String + +Resources: + LogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub "/ecs/${ServiceName}" + RetentionInDays: 7 + + Cluster: + Type: AWS::ECS::Cluster + Properties: + ClusterName: !Ref ClusterName + + TaskExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: ecs-tasks.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy + + TaskRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: ecs-tasks.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: !Sub "${ServiceName}-aws-access" + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: S3Uploads + Effect: Allow + Action: + - s3:PutObject + Resource: !Sub "arn:aws:s3:::${BhchpAwsBucketName}/*" + - Sid: SesRawEmail + Effect: Allow + Action: + - ses:SendRawEmail + Resource: !Ref BhchpAwsSesIdentityArn + - Sid: CognitoAdminProvisioning + Effect: Allow + Action: + - cognito-idp:AdminCreateUser + - cognito-idp:AdminDeleteUser + Resource: !Sub "arn:aws:cognito-idp:${CognitoRegion}:${AWS::AccountId}:userpool/${CognitoUserPoolId}" + + ServiceSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Allow inbound to the service + VpcId: !Ref VpcId + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: !Ref ContainerPort + ToPort: !Ref ContainerPort + SourceSecurityGroupId: !Ref LoadBalancerSecurityGroup + + LoadBalancerSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Allow inbound HTTP to the ALB + VpcId: !Ref VpcId + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + CidrIp: !Ref AllowedCidr + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + CidrIp: !Ref AllowedCidr + + LoadBalancer: + Type: AWS::ElasticLoadBalancingV2::LoadBalancer + Properties: + Name: !Sub "${ServiceName}-alb" + Scheme: internet-facing + Subnets: !Ref SubnetIds + SecurityGroups: + - !Ref LoadBalancerSecurityGroup + + DnsRecord: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: !Ref HostedZoneId + Name: !Ref DomainName + Type: A + AliasTarget: + DNSName: !GetAtt LoadBalancer.DNSName + HostedZoneId: !GetAtt LoadBalancer.CanonicalHostedZoneID + + TlsCertificate: + Type: AWS::CertificateManager::Certificate + Properties: + DomainName: !Ref DomainName + ValidationMethod: DNS + DomainValidationOptions: + - DomainName: !Ref DomainName + HostedZoneId: !Ref HostedZoneId + + TargetGroup: + Type: AWS::ElasticLoadBalancingV2::TargetGroup + Properties: + Name: !Sub "${ServiceName}-tg" + Port: !Ref ContainerPort + Protocol: HTTP + TargetType: ip + VpcId: !Ref VpcId + HealthCheckPath: !Ref HealthCheckPath + Matcher: + HttpCode: 200-399 + + HttpListener: + Type: AWS::ElasticLoadBalancingV2::Listener + Properties: + LoadBalancerArn: !Ref LoadBalancer + Port: 80 + Protocol: HTTP + DefaultActions: + - Type: redirect + RedirectConfig: + Protocol: HTTPS + Port: "443" + StatusCode: HTTP_301 + + HttpsListener: + Type: AWS::ElasticLoadBalancingV2::Listener + Properties: + LoadBalancerArn: !Ref LoadBalancer + Port: 443 + Protocol: HTTPS + Certificates: + - CertificateArn: !Ref TlsCertificate + DefaultActions: + - Type: forward + TargetGroupArn: !Ref TargetGroup + + TaskDefinition: + Type: AWS::ECS::TaskDefinition + Properties: + Family: !Ref ServiceName + Cpu: !Ref Cpu + Memory: !Ref Memory + NetworkMode: awsvpc + RequiresCompatibilities: + - FARGATE + ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn + TaskRoleArn: !GetAtt TaskRole.Arn + ContainerDefinitions: + - Name: app + Image: !Ref ContainerImage + Essential: true + PortMappings: + - ContainerPort: !Ref ContainerPort + Protocol: tcp + Environment: + - Name: NX_DB_HOST + Value: !Ref DbHost + - Name: NX_DB_PORT + Value: !Ref DbPort + - Name: NX_DB_DATABASE + Value: !Ref DbName + - Name: NX_DB_USERNAME + Value: !Ref DbUsername + - Name: NX_DB_PASSWORD + Value: !Ref DbPassword + - Name: NX_DB_SSL_CA_PATH + Value: /app/global-bundle.pem + - Name: BHCHP_AWS_BUCKET_NAME + Value: !Ref BhchpAwsBucketName + - Name: BHCHP_AWS_REGION + Value: !Ref "AWS::Region" + - Name: BHCHP_AWS_SES_SENDER_EMAIL + Value: !Ref BhchpAwsSesSenderEmail + - Name: COGNITO_REGION + Value: !Ref CognitoRegion + - Name: COGNITO_USER_POOL_ID + Value: !Ref CognitoUserPoolId + - Name: COGNITO_APP_CLIENT_ID + Value: !Ref CognitoAppClientId + - Name: VITE_COGNITO_APP_CLIENT_ID + Value: !Ref ViteCognitoAppClientId + LogConfiguration: + LogDriver: awslogs + Options: + awslogs-group: !Ref LogGroup + awslogs-region: !Ref "AWS::Region" + awslogs-stream-prefix: ecs + + Service: + Type: AWS::ECS::Service + DependsOn: + - LogGroup + - HttpListener + - HttpsListener + Properties: + ServiceName: !Ref ServiceName + Cluster: !Ref Cluster + LaunchType: FARGATE + DesiredCount: 1 + TaskDefinition: !Ref TaskDefinition + LoadBalancers: + - ContainerName: app + ContainerPort: !Ref ContainerPort + TargetGroupArn: !Ref TargetGroup + NetworkConfiguration: + AwsvpcConfiguration: + Subnets: !Ref SubnetIds + SecurityGroups: + - !Ref ServiceSecurityGroup + AssignPublicIp: !Ref AssignPublicIp + +Outputs: + ClusterName: + Value: !Ref Cluster + ServiceName: + Value: !Ref Service + ServiceSecurityGroupId: + Value: !Ref ServiceSecurityGroup + LoadBalancerDnsName: + Value: !GetAtt LoadBalancer.DNSName + PublicApiDomain: + Value: !Ref DomainName diff --git a/infrastructure/06-dns-acm.yml b/infrastructure/06-dns-acm.yml new file mode 100644 index 000000000..774240251 --- /dev/null +++ b/infrastructure/06-dns-acm.yml @@ -0,0 +1,57 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Optional DNS + ACM helper for the backend ALB + +Parameters: + HostedZoneId: + Type: String + Description: Route53 hosted zone ID (e.g. Z123...) + DomainName: + Type: String + Description: Domain name to point at the ALB (e.g. api.example.com) + LoadBalancerDnsName: + Type: String + Description: ALB DNS name (from the ECS stack output) + Default: "" + LoadBalancerHostedZoneId: + Type: String + Description: ALB hosted zone ID (from the ECS stack output) + Default: "" + +Conditions: + HasAlbTarget: + Fn::And: + - Fn::Not: + - Fn::Equals: + - !Ref LoadBalancerDnsName + - "" + - Fn::Not: + - Fn::Equals: + - !Ref LoadBalancerHostedZoneId + - "" + +Resources: + TlsCertificate: + Type: AWS::CertificateManager::Certificate + Properties: + DomainName: !Ref DomainName + ValidationMethod: DNS + DomainValidationOptions: + - DomainName: !Ref DomainName + HostedZoneId: !Ref HostedZoneId + + DnsRecord: + Type: AWS::Route53::RecordSet + Condition: HasAlbTarget + Properties: + HostedZoneId: !Ref HostedZoneId + Name: !Ref DomainName + Type: A + AliasTarget: + DNSName: !Ref LoadBalancerDnsName + HostedZoneId: !Ref LoadBalancerHostedZoneId + +Outputs: + AcmCertificateArn: + Value: !Ref TlsCertificate + DomainName: + Value: !Ref DomainName diff --git a/infrastructure/07-amplify-app.yml b/infrastructure/07-amplify-app.yml new file mode 100644 index 000000000..e9a5d7cb4 --- /dev/null +++ b/infrastructure/07-amplify-app.yml @@ -0,0 +1,98 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Amplify app for proj-bhchp frontend (production branch) + +Parameters: + AppName: + Type: String + RepositoryUrl: + Type: String + OAuthToken: + Type: String + NoEcho: true + Description: GitHub OAuth token with repo access + BranchName: + Type: String + ViteApiBaseUrl: + Type: String + ViteAwsRegion: + Type: String + ViteCognitoAppClientId: + Type: String + ViteCognitoRegion: + Type: String + ViteCognitoUserPoolId: + Type: String + ViteS3BucketAddr: + Type: String + +Resources: + AmplifyApp: + Type: AWS::Amplify::App + Properties: + Name: !Ref AppName + Repository: !Ref RepositoryUrl + OauthToken: !Ref OAuthToken + Platform: WEB + BuildSpec: | + version: 1 + frontend: + phases: + preBuild: + commands: + - 'yarn install' + build: + commands: + - 'yarn build:frontend' + artifacts: + baseDirectory: dist/apps/frontend + files: + - '**/*' + cache: + paths: + - 'node_modules/**/*' + EnvironmentVariables: + - Name: VITE_API_BASE_URL + Value: !Ref ViteApiBaseUrl + - Name: VITE_AWS_REGION + Value: !Ref ViteAwsRegion + - Name: VITE_COGNITO_APP_CLIENT_ID + Value: !Ref ViteCognitoAppClientId + - Name: VITE_COGNITO_REGION + Value: !Ref ViteCognitoRegion + - Name: VITE_COGNITO_USER_POOL_ID + Value: !Ref ViteCognitoUserPoolId + - Name: VITE_S3_BUCKET_ADDR + Value: !Ref ViteS3BucketAddr + CustomRules: + - Source: "/<*>" + Target: "/index.html" + Status: "404" + + AmplifyBranch: + Type: AWS::Amplify::Branch + Properties: + AppId: !GetAtt AmplifyApp.AppId + BranchName: !Ref BranchName + Stage: PRODUCTION + EnableAutoBuild: true + EnvironmentVariables: + - Name: VITE_API_BASE_URL + Value: !Ref ViteApiBaseUrl + - Name: VITE_AWS_REGION + Value: !Ref ViteAwsRegion + - Name: VITE_COGNITO_APP_CLIENT_ID + Value: !Ref ViteCognitoAppClientId + - Name: VITE_COGNITO_REGION + Value: !Ref ViteCognitoRegion + - Name: VITE_COGNITO_USER_POOL_ID + Value: !Ref ViteCognitoUserPoolId + - Name: VITE_S3_BUCKET_ADDR + Value: !Ref ViteS3BucketAddr + +Outputs: + AmplifyAppId: + Value: !GetAtt AmplifyApp.AppId + AmplifyDefaultDomain: + Value: !GetAtt AmplifyApp.DefaultDomain + AmplifyBranchUrl: + Value: !Sub "https://${BranchName}.${AmplifyApp.AppId}.${AmplifyApp.DefaultDomain}" diff --git a/infrastructure/README.md b/infrastructure/README.md new file mode 100644 index 000000000..5453923a8 --- /dev/null +++ b/infrastructure/README.md @@ -0,0 +1,210 @@ +# Infrastructure + +This folder contains CloudFormation templates for infrastructure used by the app. + +## RDS Postgres (private, cheapest practical config) + +Template: `rds-postgres.yml` + +### Prereqs + +# Infrastructure + +This folder contains CloudFormation templates for bringing up the full BHCHP stack. +The numbered filenames below match the recommended deployment order. + +## File map (numbered) + +- Templates: [infrastructure/01-s3-bucket.yml](01-s3-bucket.yml), [infrastructure/02-ecr.yml](02-ecr.yml), [infrastructure/03-cognito.yml](03-cognito.yml), [infrastructure/04-rds-postgres.yml](04-rds-postgres.yml), [infrastructure/05-ecs-fargate.yml](05-ecs-fargate.yml), [infrastructure/06-dns-acm.yml](06-dns-acm.yml), [infrastructure/07-amplify-app.yml](07-amplify-app.yml) +- Parameter templates: [infrastructure/params-skeletons/01-s3-bucket.json](params-skeletons/01-s3-bucket.json), [infrastructure/params-skeletons/03-cognito.json](params-skeletons/03-cognito.json), [infrastructure/params-skeletons/04-rds-postgres.json](params-skeletons/04-rds-postgres.json), [infrastructure/params-skeletons/05-ecs-fargate.json](params-skeletons/05-ecs-fargate.json), [infrastructure/params-skeletons/06-dns-acm.json](params-skeletons/06-dns-acm.json), [infrastructure/params-skeletons/07-amplify-app.json](params-skeletons/07-amplify-app.json) +- Filled parameters live in [infrastructure/params](params) + +## Prereqs + +- AWS CLI configured for the target account/region. +- A VPC with subnets (private for RDS, public for ECS if `AssignPublicIp=ENABLED`). +- A Route53 hosted zone and domain for the API (used by the ECS stack). +- A verified SES identity ARN for the backend email sender. +- A GitHub classic PAT for Amplify (repo or public_repo scope). + +## Step-by-step bring-up (end-to-end) + +### 0) Create your parameter files + +Copy the skeletons into [infrastructure/params](params) and fill in values. + +```bash +cp infrastructure/params-skeletons/01-s3-bucket.json infrastructure/params/01-s3-bucket.json +cp infrastructure/params-skeletons/03-cognito.json infrastructure/params/03-cognito.json +cp infrastructure/params-skeletons/04-rds-postgres.json infrastructure/params/04-rds-postgres.json +cp infrastructure/params-skeletons/05-ecs-fargate.json infrastructure/params/05-ecs-fargate.json +cp infrastructure/params-skeletons/06-dns-acm.json infrastructure/params/06-dns-acm.json +cp infrastructure/params-skeletons/07-amplify-app.json infrastructure/params/07-amplify-app.json +``` + +### 1) S3 bucket (01) + +Create the bucket used by the backend (and referenced by the frontend). + +```bash +aws cloudformation create-stack \ + --region us-east-2 \ + --stack-name bhchp-s3 \ + --template-body file://infrastructure/01-s3-bucket.yml \ + --parameters file://infrastructure/params/01-s3-bucket.json +``` + +Outputs to reuse: +- `BucketName` → set ECS `BhchpAwsBucketName` +- `BucketName` → set Amplify `ViteS3BucketAddr` to `${BucketName}.s3.us-east-2.amazonaws.com/` + +#### 1.5) S3 Bucket setup +In the AWS console, create the following folders: +- cover-letters +- resumes +- syllabus +Use these EXACT names. + +Then for the confidentiality form please upload the file with name "Confidentiality_Form.pdf" (EXACT) + +### 2) ECR repository (02) + +Use this if you want a managed ECR repo for the backend image. + +```bash +aws cloudformation create-stack \ + --region us-east-2 \ + --stack-name bhchp-ecr \ + --template-body file://infrastructure/02-ecr.yml +``` + +Outputs to reuse: +- `RepositoryUri` → set ECS `ContainerImage` as `${RepositoryUri}:latest` (or your tag) + +#### 2.5) ECR Image Push +Build the image by running + +```bash + docker build -t bhchp-backend -f apps/backend/Dockerfile . +``` + +Then follow the tagging and pushing instructions using the RepositoryUri +```bash +aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin ${RepositoryUri} + +docker tag bhchp-backend:latest ${RepositoryUri}/bhchp-backend:latest + +docker push ${RepositoryUri}/bhchp-backend:latest +``` + +### 3) Cognito user pool + client (03) + +```bash +aws cloudformation create-stack \ + --region us-east-2 \ + --stack-name bhchp-cognito \ + --template-body file://infrastructure/03-cognito.yml \ + --parameters file://infrastructure/params/03-cognito.json +``` + +Outputs to reuse: +- `CognitoUserPoolId` → set ECS `CognitoUserPoolId` and Amplify `ViteCognitoUserPoolId` +- `ViteCognitoAppClientId` → set ECS `CognitoAppClientId` and `ViteCognitoAppClientId` +- `CognitoRegion` → set ECS `CognitoRegion` and Amplify `ViteCognitoRegion` + +#### 3.5) Cognito first admin +In the AWS Console, navigate to the Cognito console. + +User pools -> bhchp-user-pool (or alternative name) -> Users + +Press "Create Users" on the top right + +Invitation message = Don't send an invitation +Email address = your email +CHECK Mark email address as verified + +### 4) RDS Postgres (04) + +Create the database. Leave `ECSSecurityGroupId` empty for now; you will update it after ECS is up. + +```bash +aws cloudformation create-stack \ + --region us-east-2 \ + --stack-name bhchp-rds \ + --template-body file://infrastructure/04-rds-postgres.yml \ + --parameters file://infrastructure/params/04-rds-postgres.json +``` + +Outputs to reuse: +- `DbEndpoint` → set ECS `DbHost` +- `DbPort` → set ECS `DbPort` + +#### 4.5) Migrate Schema to Postgres +Inside of the cloned BHCHP repository + +TODO: WRITE + + +### 5) ECS Fargate backend (05) + +```bash +aws cloudformation create-stack \ + --region us-east-2 \ + --stack-name bhchp-ecs \ + --template-body file://infrastructure/05-ecs-fargate.yml \ + --parameters file://infrastructure/params/05-ecs-fargate.json +``` + +Make sure the ECS params include: +- `DbHost` and `DbPort` from the RDS outputs +- `BhchpAwsBucketName` from the S3 output +- `CognitoUserPoolId`, `CognitoRegion`, and both `CognitoAppClientId` + `ViteCognitoAppClientId` from the Cognito output + +Outputs to reuse: +- `ServiceSecurityGroupId` → update the RDS stack to allow ECS access +- `PublicApiDomain` → set Amplify `ViteApiBaseUrl` as `https://{PublicApiDomain}` + +### 6) Update RDS to allow ECS access + +Update your RDS params file with `ECSSecurityGroupId={ServiceSecurityGroupId}` and run: + +```bash +aws cloudformation update-stack \ + --region us-east-2 \ + --stack-name bhchp-rds \ + --template-body file://infrastructure/04-rds-postgres.yml \ + --parameters file://infrastructure/params/04-rds-postgres.json +``` + +### 7) DNS + ACM helper (06, optional) + +This stack is optional. The ECS template already provisions an ACM certificate and Route53 record. +Use this only if you want a standalone certificate/DNS record for another service. + +```bash +aws cloudformation create-stack \ + --region us-east-2 \ + --stack-name bhchp-dns-acm \ + --template-body file://infrastructure/06-dns-acm.yml \ + --parameters file://infrastructure/params/06-dns-acm.json +``` + +### 8) Amplify frontend hosting (07) + +```bash +aws cloudformation create-stack \ + --region us-east-2 \ + --stack-name bhchp-amplify \ + --template-body file://infrastructure/07-amplify-app.yml \ + --parameters file://infrastructure/params/07-amplify-app.json +``` + +Make sure the Amplify params include: +- `ViteApiBaseUrl` → `https://{PublicApiDomain}` from ECS +- `ViteCognitoUserPoolId`, `ViteCognitoAppClientId`, `ViteCognitoRegion` → from Cognito +- `ViteS3BucketAddr` → `${BucketName}.s3.us-east-2.amazonaws.com/` from S3 + +## Updating stacks + +Replace `create-stack` with `update-stack` once the stack exists. + --region us-east-2 \ diff --git a/infrastructure/params-skeletons/01-s3-bucket.json b/infrastructure/params-skeletons/01-s3-bucket.json new file mode 100644 index 000000000..81e4faf78 --- /dev/null +++ b/infrastructure/params-skeletons/01-s3-bucket.json @@ -0,0 +1,3 @@ +[ + { "ParameterKey": "Name", "ParameterValue": "bhchp-bucket" } +] \ No newline at end of file diff --git a/infrastructure/params-skeletons/03-cognito.json b/infrastructure/params-skeletons/03-cognito.json new file mode 100644 index 000000000..e7798fd8b --- /dev/null +++ b/infrastructure/params-skeletons/03-cognito.json @@ -0,0 +1,6 @@ +[ + { "ParameterKey": "CognitoRegion", "ParameterValue": "us-east-2" }, + { "ParameterKey": "UserPoolName", "ParameterValue": "bhchp-user-pool-2" }, + { "ParameterKey": "FrontendClientName", "ParameterValue": "bhchp-frontend-no-secret-2" }, + { "ParameterKey": "FrontendRefreshTokenValidityDays", "ParameterValue": "30" } +] \ No newline at end of file diff --git a/infrastructure/params-skeletons/04-rds-postgres.json b/infrastructure/params-skeletons/04-rds-postgres.json new file mode 100644 index 000000000..12b658b2b --- /dev/null +++ b/infrastructure/params-skeletons/04-rds-postgres.json @@ -0,0 +1,8 @@ +[ + { "ParameterKey": "VpcId", "ParameterValue": "" }, + { "ParameterKey": "SubnetIds", "ParameterValue": "" }, + { "ParameterKey": "DBPassword", "ParameterValue": "" }, + { "ParameterKey": "ECSSecurityGroupId", "ParameterValue": "sg-ecs" }, + { "ParameterKey": "AllowedCidr", "ParameterValue": "" }, + { "ParameterKey": "PubliclyAccessible", "ParameterValue": "false" } +] \ No newline at end of file diff --git a/infrastructure/params-skeletons/05-ecs-fargate.json b/infrastructure/params-skeletons/05-ecs-fargate.json new file mode 100644 index 000000000..ad7f9b29f --- /dev/null +++ b/infrastructure/params-skeletons/05-ecs-fargate.json @@ -0,0 +1,27 @@ +[ + { "ParameterKey": "HealthCheckPath", "ParameterValue": "" }, + { "ParameterKey": "VpcId", "ParameterValue": "" }, + { "ParameterKey": "SubnetIds", "ParameterValue": "" }, + { "ParameterKey": "AssignPublicIp", "ParameterValue": "ENABLED" }, + { "ParameterKey": "AllowedCidr", "ParameterValue": "0.0.0.0/0" }, + { "ParameterKey": "HostedZoneId", "ParameterValue": "" }, + { "ParameterKey": "DomainName", "ParameterValue": "" }, + { "ParameterKey": "ClusterName", "ParameterValue": "bhchp-ecs" }, + { "ParameterKey": "ServiceName", "ParameterValue": "bhchp-backend" }, + { "ParameterKey": "ContainerImage", "ParameterValue": "" }, + { "ParameterKey": "ContainerPort", "ParameterValue": "3000" }, + { "ParameterKey": "Cpu", "ParameterValue": "256" }, + { "ParameterKey": "Memory", "ParameterValue": "512" }, + { "ParameterKey": "DbHost", "ParameterValue": "" }, + { "ParameterKey": "DbPort", "ParameterValue": "5432" }, + { "ParameterKey": "DbName", "ParameterValue": "bhchp" }, + { "ParameterKey": "DbUsername", "ParameterValue": "postgres" }, + { "ParameterKey": "DbPassword", "ParameterValue": "" }, + { "ParameterKey": "BhchpAwsBucketName", "ParameterValue": "" }, + { "ParameterKey": "BhchpAwsSesSenderEmail", "ParameterValue": "" }, + { "ParameterKey": "BhchpAwsSesIdentityArn", "ParameterValue": "" }, + { "ParameterKey": "CognitoRegion", "ParameterValue": "us-east-2" }, + { "ParameterKey": "CognitoUserPoolId", "ParameterValue": ""}, + { "ParameterKey": "CognitoAppClientId", "ParameterValue": ""}, + { "ParameterKey": "ViteCognitoAppClientId", "ParameterValue": ""} +] diff --git a/infrastructure/params-skeletons/06-dns-acm.json b/infrastructure/params-skeletons/06-dns-acm.json new file mode 100644 index 000000000..a2a30e42d --- /dev/null +++ b/infrastructure/params-skeletons/06-dns-acm.json @@ -0,0 +1,6 @@ +[ + { "ParameterKey": "HostedZoneId", "ParameterValue": "" }, + { "ParameterKey": "DomainName", "ParameterValue": "" }, + { "ParameterKey": "LoadBalancerDnsName", "ParameterValue": "" }, + { "ParameterKey": "LoadBalancerHostedZoneId", "ParameterValue": "" } +] diff --git a/infrastructure/params-skeletons/07-amplify-app.json b/infrastructure/params-skeletons/07-amplify-app.json new file mode 100644 index 000000000..d16716b1e --- /dev/null +++ b/infrastructure/params-skeletons/07-amplify-app.json @@ -0,0 +1,12 @@ +[ + { "ParameterKey": "AppName", "ParameterValue": "proj-bhchp" }, + { "ParameterKey": "RepositoryUrl", "ParameterValue": "https://github.com/Code-4-Community/proj-bhchp" }, + { "ParameterKey": "OAuthToken", "ParameterValue": "" }, + { "ParameterKey": "BranchName", "ParameterValue": "production" }, + { "ParameterKey": "ViteApiBaseUrl", "ParameterValue": "" }, + { "ParameterKey": "ViteAwsRegion", "ParameterValue": "us-east-2" }, + { "ParameterKey": "ViteCognitoAppClientId", "ParameterValue": "" }, + { "ParameterKey": "ViteCognitoRegion", "ParameterValue": "us-east-2" }, + { "ParameterKey": "ViteCognitoUserPoolId", "ParameterValue": "" }, + { "ParameterKey": "ViteS3BucketAddr", "ParameterValue": "" } +] diff --git a/package.json b/package.json index 10ad9b515..264496bbc 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "version": "0.0.0", "license": "MIT", "scripts": { + "build:frontend": "npx nx build frontend", + "build:backend": "npx nx build backend", "format:check": "prettier --no-error-on-unmatched-pattern --check apps/{frontend,backend}/src/**/*.{js,ts,tsx}", "format": "prettier --no-error-on-unmatched-pattern --write apps/{frontend,backend}/src/**/*.{js,ts,tsx}", "lint:check": "eslint apps/frontend --ext .ts,.tsx && eslint apps/backend --ext .ts,.tsx",