From 423ba355e3f2ad7bd38be33fcad15a53c3d8daa3 Mon Sep 17 00:00:00 2001 From: Chris Wilton-Magras Date: Wed, 6 Mar 2024 12:51:52 +0000 Subject: [PATCH 1/5] Add DNS and certificate to cloudfront and fargate --- cloud/bin/cloud.ts | 15 ++++++++++-- cloud/lib/api-stack.ts | 28 +++++++++++++++++---- cloud/lib/index.ts | 1 + cloud/lib/routing-stack.ts | 38 +++++++++++++++++++++++++++++ cloud/lib/ui-stack.ts | 50 +++++++++++++++++++++++--------------- 5 files changed, 105 insertions(+), 27 deletions(-) create mode 100644 cloud/lib/routing-stack.ts diff --git a/cloud/bin/cloud.ts b/cloud/bin/cloud.ts index 342f8baf9..3c34f5d75 100644 --- a/cloud/bin/cloud.ts +++ b/cloud/bin/cloud.ts @@ -9,6 +9,7 @@ import { stackName, ApiStack, AuthStack, + RoutingStack, UiStack, } from '../lib'; @@ -29,25 +30,35 @@ const tags = { const generateStackName = stackName(app); const generateDescription = resourceDescription(app); +const routingStack = new RoutingStack(app, generateStackName('routing'), { + description: generateDescription('Route 53 stack'), + env, + tags, +}); + const uiStack = new UiStack(app, generateStackName('ui'), { description: generateDescription('UI stack'), env, tags, + certificate: routingStack.certificate, + hostedZone: routingStack.hostedZone, }); /*const authStack = */ new AuthStack(app, generateStackName('auth'), { description: generateDescription('Auth stack'), env, tags, - webappUrl: uiStack.cloudfrontUrl, + webappUrl: uiStack.cloudFrontUrl, }); new ApiStack(app, generateStackName('api'), { description: generateDescription('API stack'), env, tags, + certificate: routingStack.certificate, + hostedZone: routingStack.hostedZone, // userPool: authStack.userPool, // userPoolClient: authStack.userPoolClient, // userPoolDomain: authStack.userPoolDomain, - webappUrl: uiStack.cloudfrontUrl, + webappUrl: uiStack.cloudFrontUrl, }); diff --git a/cloud/lib/api-stack.ts b/cloud/lib/api-stack.ts index 52c683290..5d23e2106 100644 --- a/cloud/lib/api-stack.ts +++ b/cloud/lib/api-stack.ts @@ -1,5 +1,6 @@ import { CorsHttpMethod, HttpApi, VpcLink } from 'aws-cdk-lib/aws-apigatewayv2'; import { HttpAlbIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations'; +import { ICertificate } from 'aws-cdk-lib/aws-certificatemanager'; //import { UserPool, UserPoolClient, UserPoolDomain } from 'aws-cdk-lib/aws-cognito'; import { Port, SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2'; import { DockerImageAsset } from 'aws-cdk-lib/aws-ecr-assets'; @@ -10,7 +11,7 @@ import { Secret as EnvSecret, } from 'aws-cdk-lib/aws-ecs'; import { ApplicationLoadBalancedFargateService } from 'aws-cdk-lib/aws-ecs-patterns'; -//import { ListenerAction } from 'aws-cdk-lib/aws-elasticloadbalancingv2'; +//import { ListenerAction, ListenerCondition } from 'aws-cdk-lib/aws-elasticloadbalancingv2'; //import { AuthenticateCognitoAction } from 'aws-cdk-lib/aws-elasticloadbalancingv2-actions'; import { Effect, @@ -20,6 +21,8 @@ import { } from 'aws-cdk-lib/aws-iam'; import { Runtime } from 'aws-cdk-lib/aws-lambda'; import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import { ARecord, IHostedZone, RecordTarget } from 'aws-cdk-lib/aws-route53'; +import { LoadBalancerTarget } from 'aws-cdk-lib/aws-route53-targets'; import { Bucket } from 'aws-cdk-lib/aws-s3'; import { CronOptionsWithTimezone, @@ -52,6 +55,8 @@ type ApiStackProps = StackProps & { // userPool: UserPool; // userPoolClient: UserPoolClient; // userPoolDomain: UserPoolDomain; + certificate: ICertificate; + hostedZone: IHostedZone; webappUrl: string; }; @@ -59,7 +64,8 @@ export class ApiStack extends Stack { constructor(scope: Construct, id: string, props: ApiStackProps) { super(scope, id, props); // TODO Enable cognito/JWT authorization - const { /*userPool, userPoolClient, userPoolDomain,*/ webappUrl } = props; + const { certificate, hostedZone, webappUrl } = props; + const domainName = `api.${hostedZone.zoneName}`; const generateResourceName = resourceName(scope); const generateResourceDescription = resourceDescription(scope); @@ -121,6 +127,9 @@ export class ApiStack extends Stack { loadBalancerName, openListener: false, publicLoadBalancer: false, + certificate, + domainName, + domainZone: hostedZone, propagateTags: PropagatedTagSource.SERVICE, } ); @@ -135,6 +144,16 @@ export class ApiStack extends Stack { }) ); + // DNS A Record for Route53 + const loadBalancerARecordName = generateResourceName('arecord-alb'); + new ARecord(this, loadBalancerARecordName, { + zone: hostedZone, + target: RecordTarget.fromAlias(new LoadBalancerTarget(fargateService.loadBalancer)), + deleteExisting: true, + recordName: domainName, + comment: 'DNS A Record for the load-balanced API', + }); + // Lambda to bring fargate service up or down const startStopFunctionName = generateResourceName('fargate-switch'); const startStopServiceFunction = new NodejsFunction( @@ -209,10 +228,8 @@ export class ApiStack extends Stack { }), }); - // Hook up Cognito to load balancer + // TODO Hook up Cognito to load balancer, then remove API Gateway shenanigans // https://stackoverflow.com/q/71124324 - // TODO Needs HTTPS and a Route53 domain, so for now we're using APIGateway and VPCLink: - // https://repost.aws/knowledge-center/api-gateway-alb-integration /* const authActionName = generateResourceName('alb-auth'); fargateService.listener.addAction(authActionName, { @@ -222,6 +239,7 @@ export class ApiStack extends Stack { userPoolDomain, next: ListenerAction.forward([fargateService.targetGroup]), }), + conditions: [ListenerCondition.hostHeaders([domainName])], }); */ diff --git a/cloud/lib/index.ts b/cloud/lib/index.ts index 0427fcf54..32c0c196d 100644 --- a/cloud/lib/index.ts +++ b/cloud/lib/index.ts @@ -1,4 +1,5 @@ export * from './resourceNamingUtils'; export { ApiStack } from './api-stack'; export { AuthStack } from './auth-stack'; +export { RoutingStack } from './routing-stack'; export { UiStack } from './ui-stack'; diff --git a/cloud/lib/routing-stack.ts b/cloud/lib/routing-stack.ts new file mode 100644 index 000000000..d0b10471c --- /dev/null +++ b/cloud/lib/routing-stack.ts @@ -0,0 +1,38 @@ +import { Certificate, CertificateValidation, ICertificate } from 'aws-cdk-lib/aws-certificatemanager'; +import { HostedZone, IHostedZone } from 'aws-cdk-lib/aws-route53'; +import { RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; + +import { resourceName } from './resourceNamingUtils'; + +export class RoutingStack extends Stack { + public readonly certificate: ICertificate; + public readonly hostedZone: IHostedZone; + + constructor(scope: Construct, id: string, props: StackProps) { + super(scope, id, props); + + const generateResourceName = resourceName(scope); + + const { DOMAIN_NAME, HOSTED_ZONE_ID } = process.env; + if (!HOSTED_ZONE_ID) throw new Error('HOSTED_ZONE_ID not found in env vars'); + if (!DOMAIN_NAME) throw new Error('DOMAIN_NAME not found in env vars'); + + this.hostedZone = HostedZone.fromHostedZoneAttributes( + this, + generateResourceName('hosted-zone'), + { + hostedZoneId: HOSTED_ZONE_ID, + zoneName: DOMAIN_NAME, + } + ); + this.hostedZone.applyRemovalPolicy(RemovalPolicy.RETAIN); + + const certificateName = generateResourceName('ssl-certificate'); + this.certificate = new Certificate(this, certificateName, { + certificateName, + domainName: DOMAIN_NAME, + validation: CertificateValidation.fromDns(this.hostedZone), + }); + } +} \ No newline at end of file diff --git a/cloud/lib/ui-stack.ts b/cloud/lib/ui-stack.ts index 6f44ed0bc..11166ff04 100644 --- a/cloud/lib/ui-stack.ts +++ b/cloud/lib/ui-stack.ts @@ -8,38 +8,36 @@ import { ViewerProtocolPolicy, } from 'aws-cdk-lib/aws-cloudfront'; import { S3Origin } from 'aws-cdk-lib/aws-cloudfront-origins'; -import { - CfnOutput, - Duration, - RemovalPolicy, - Stack, - StackProps, -} from 'aws-cdk-lib/core'; +import { CfnOutput, Duration, RemovalPolicy, Stack, StackProps, } from 'aws-cdk-lib/core'; import * as iam from 'aws-cdk-lib/aws-iam'; -import { - BlockPublicAccess, - Bucket, - BucketEncryption, -} from 'aws-cdk-lib/aws-s3'; +import { BlockPublicAccess, Bucket, BucketEncryption, } from 'aws-cdk-lib/aws-s3'; import { Construct } from 'constructs'; import { appName, resourceName } from './resourceNamingUtils'; +import { AaaaRecord, IHostedZone, RecordTarget } from 'aws-cdk-lib/aws-route53'; +import { CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets'; +import { ICertificate } from 'aws-cdk-lib/aws-certificatemanager'; + +type UiStackProps = StackProps & { + certificate: ICertificate; + hostedZone: IHostedZone; +} export class UiStack extends Stack { - public readonly cloudfrontUrl: string; + public readonly cloudFrontUrl: string; - constructor(scope: Construct, id: string, props: StackProps) { + constructor(scope: Construct, id: string, props: UiStackProps) { super(scope, id, props); const generateResourceName = resourceName(scope); + const { certificate, hostedZone } = props; - // allow s3 to be secured const cloudfrontOAI = new OriginAccessIdentity( this, generateResourceName('cloudfront-OAI') ); - //HostBucket + // Host Bucket const bucketName = generateResourceName('host-bucket'); const hostBucket = new Bucket(this, bucketName, { bucketName, @@ -61,14 +59,16 @@ export class UiStack extends Stack { }) ); - //CloudFront + // CloudFront Distribution const cachePolicyName = generateResourceName('site-cache-policy'); - const cloudFront = new Distribution( + const cloudFrontDistribution = new Distribution( this, generateResourceName('site-distribution'), { defaultRootObject: 'index.html', minimumProtocolVersion: SecurityPolicyProtocol.TLS_V1_2_2021, + certificate, + domainNames: [hostedZone.zoneName], errorResponses: [ { httpStatus: 404, @@ -89,12 +89,22 @@ export class UiStack extends Stack { allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS, viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, }, + } ); - this.cloudfrontUrl = `https://${cloudFront.domainName}`; + // DNS AAAA Record for Route53 + const cloudFrontARecordName = generateResourceName('arecord-cfront'); + new AaaaRecord(this, cloudFrontARecordName, { + zone: hostedZone, + target: RecordTarget.fromAlias(new CloudFrontTarget(cloudFrontDistribution)), + deleteExisting: true, + comment: 'DNS AAAA Record for the UI host', + }); + + this.cloudFrontUrl = `https://${cloudFrontDistribution.domainName}`; new CfnOutput(this, 'WebURL', { - value: this.cloudfrontUrl, + value: this.cloudFrontUrl, }); } } From 366cf1e1d73f0d5aeb83c02ba003f08a939e7dc7 Mon Sep 17 00:00:00 2001 From: Chris Wilton-Magras Date: Wed, 6 Mar 2024 17:20:06 +0000 Subject: [PATCH 2/5] Remove API GW, make ALB public, use Nat Instance for cost savings --- cloud/bin/cloud.ts | 2 +- cloud/lib/api-stack.ts | 88 +++++++++----------------------------- cloud/lib/routing-stack.ts | 11 +++-- cloud/lib/ui-stack.ts | 28 ++++++++---- 4 files changed, 49 insertions(+), 80 deletions(-) diff --git a/cloud/bin/cloud.ts b/cloud/bin/cloud.ts index 3c34f5d75..0ec8c1f59 100644 --- a/cloud/bin/cloud.ts +++ b/cloud/bin/cloud.ts @@ -24,7 +24,7 @@ const tags = { owner: appName, classification: 'unrestricted', 'environment-type': environmentName(app), - 'keep-alive': '8-6-without-weekends', + 'keep-alive': '9-5-without-weekends', }; const generateStackName = stackName(app); diff --git a/cloud/lib/api-stack.ts b/cloud/lib/api-stack.ts index 5d23e2106..a20d6f6a4 100644 --- a/cloud/lib/api-stack.ts +++ b/cloud/lib/api-stack.ts @@ -1,8 +1,12 @@ -import { CorsHttpMethod, HttpApi, VpcLink } from 'aws-cdk-lib/aws-apigatewayv2'; -import { HttpAlbIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations'; import { ICertificate } from 'aws-cdk-lib/aws-certificatemanager'; //import { UserPool, UserPoolClient, UserPoolDomain } from 'aws-cdk-lib/aws-cognito'; -import { Port, SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2'; +import { + InstanceClass, + InstanceSize, + InstanceType, + NatInstanceProvider, + Vpc, +} from 'aws-cdk-lib/aws-ec2'; import { DockerImageAsset } from 'aws-cdk-lib/aws-ecr-assets'; import { Cluster, @@ -33,14 +37,7 @@ import { } from '@aws-cdk/aws-scheduler-alpha'; import { LambdaInvoke } from '@aws-cdk/aws-scheduler-targets-alpha'; import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; -import { - CfnOutput, - RemovalPolicy, - Stack, - StackProps, - Tags, - TimeZone, -} from 'aws-cdk-lib/core'; +import { RemovalPolicy, Stack, StackProps, TimeZone } from 'aws-cdk-lib/core'; import { Construct } from 'constructs'; import { join } from 'node:path'; @@ -80,7 +77,13 @@ export class ApiStack extends Stack { // Default AZs is all in region, but for environment-agnostic stack, max is 2! const vpcName = generateResourceName('vpc'); - const vpc = new Vpc(this, vpcName, { vpcName, maxAzs: 2 }); + const vpc = new Vpc(this, vpcName, { + vpcName, + maxAzs: 2, + natGatewayProvider: NatInstanceProvider.instance({ + instanceType: InstanceType.of(InstanceClass.T3, InstanceSize.MICRO), + }), + }); const clusterName = generateResourceName('cluster'); const cluster = new Cluster(this, clusterName, { clusterName, vpc }); @@ -102,7 +105,7 @@ export class ApiStack extends Stack { serviceName: fargateServiceName, cluster, cpu: 256, // Default is 256 - desiredCount: 1, // Bump this up for prod! + desiredCount: 1, taskImageOptions: { image: ContainerImage.fromDockerImageAsset(dockerImageAsset), containerPort, @@ -126,7 +129,7 @@ export class ApiStack extends Stack { memoryLimitMiB: 512, // Default is 512 loadBalancerName, openListener: false, - publicLoadBalancer: false, + publicLoadBalancer: true, certificate, domainName, domainZone: hostedZone, @@ -148,7 +151,9 @@ export class ApiStack extends Stack { const loadBalancerARecordName = generateResourceName('arecord-alb'); new ARecord(this, loadBalancerARecordName, { zone: hostedZone, - target: RecordTarget.fromAlias(new LoadBalancerTarget(fargateService.loadBalancer)), + target: RecordTarget.fromAlias( + new LoadBalancerTarget(fargateService.loadBalancer) + ), deleteExisting: true, recordName: domainName, comment: 'DNS A Record for the load-balanced API', @@ -242,58 +247,5 @@ export class ApiStack extends Stack { conditions: [ListenerCondition.hostHeaders([domainName])], }); */ - - // Create an HTTP APIGateway with a VPCLink integrated with our load balancer - const securityGroupName = generateResourceName('vpclink-sg'); - const vpcLinkSecurityGroup = new SecurityGroup(this, securityGroupName, { - vpc, - securityGroupName, - allowAllOutbound: false, - }); - vpcLinkSecurityGroup.connections.allowFromAnyIpv4( - Port.tcp(80), - 'APIGW to VPCLink' - ); - vpcLinkSecurityGroup.connections.allowTo( - fargateService.loadBalancer, - Port.tcp(80), - 'VPCLink to ALB' - ); - - const vpcLinkName = generateResourceName('vpclink'); - const vpcLink = new VpcLink(this, vpcLinkName, { - vpc, - vpcLinkName, - securityGroups: [vpcLinkSecurityGroup], - }); - Object.entries(props.tags ?? {}).forEach(([key, value]) => { - Tags.of(vpcLink).add(key, value); - }); - - const apiName = generateResourceName('api'); - const api = new HttpApi(this, apiName, { - apiName, - description: generateResourceDescription('API'), - corsPreflight: { - allowOrigins: [webappUrl], - allowMethods: [CorsHttpMethod.ANY], - allowHeaders: ['Content-Type', 'Authorization'], - allowCredentials: true, - }, - }); - api.addRoutes({ - path: '/{proxy+}', - integration: new HttpAlbIntegration( - generateResourceName('api-integration'), - fargateService.loadBalancer.listeners[0], - { vpcLink } - ), - }); - - new CfnOutput(this, 'APIGatewayURL', { - value: - api.defaultStage?.url ?? - 'FATAL ERROR: Gateway does not have a default stage', - }); } } diff --git a/cloud/lib/routing-stack.ts b/cloud/lib/routing-stack.ts index d0b10471c..ed91cb659 100644 --- a/cloud/lib/routing-stack.ts +++ b/cloud/lib/routing-stack.ts @@ -1,4 +1,8 @@ -import { Certificate, CertificateValidation, ICertificate } from 'aws-cdk-lib/aws-certificatemanager'; +import { + Certificate, + CertificateValidation, + ICertificate, +} from 'aws-cdk-lib/aws-certificatemanager'; import { HostedZone, IHostedZone } from 'aws-cdk-lib/aws-route53'; import { RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib/core'; import { Construct } from 'constructs'; @@ -15,7 +19,8 @@ export class RoutingStack extends Stack { const generateResourceName = resourceName(scope); const { DOMAIN_NAME, HOSTED_ZONE_ID } = process.env; - if (!HOSTED_ZONE_ID) throw new Error('HOSTED_ZONE_ID not found in env vars'); + if (!HOSTED_ZONE_ID) + throw new Error('HOSTED_ZONE_ID not found in env vars'); if (!DOMAIN_NAME) throw new Error('DOMAIN_NAME not found in env vars'); this.hostedZone = HostedZone.fromHostedZoneAttributes( @@ -35,4 +40,4 @@ export class RoutingStack extends Stack { validation: CertificateValidation.fromDns(this.hostedZone), }); } -} \ No newline at end of file +} diff --git a/cloud/lib/ui-stack.ts b/cloud/lib/ui-stack.ts index 11166ff04..7db40af7e 100644 --- a/cloud/lib/ui-stack.ts +++ b/cloud/lib/ui-stack.ts @@ -1,3 +1,4 @@ +import { ICertificate } from 'aws-cdk-lib/aws-certificatemanager'; import { AllowedMethods, CacheCookieBehavior, @@ -8,20 +9,29 @@ import { ViewerProtocolPolicy, } from 'aws-cdk-lib/aws-cloudfront'; import { S3Origin } from 'aws-cdk-lib/aws-cloudfront-origins'; -import { CfnOutput, Duration, RemovalPolicy, Stack, StackProps, } from 'aws-cdk-lib/core'; +import { + CfnOutput, + Duration, + RemovalPolicy, + Stack, + StackProps, +} from 'aws-cdk-lib/core'; import * as iam from 'aws-cdk-lib/aws-iam'; -import { BlockPublicAccess, Bucket, BucketEncryption, } from 'aws-cdk-lib/aws-s3'; +import { AaaaRecord, IHostedZone, RecordTarget } from 'aws-cdk-lib/aws-route53'; +import { CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets'; +import { + BlockPublicAccess, + Bucket, + BucketEncryption, +} from 'aws-cdk-lib/aws-s3'; import { Construct } from 'constructs'; import { appName, resourceName } from './resourceNamingUtils'; -import { AaaaRecord, IHostedZone, RecordTarget } from 'aws-cdk-lib/aws-route53'; -import { CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets'; -import { ICertificate } from 'aws-cdk-lib/aws-certificatemanager'; type UiStackProps = StackProps & { certificate: ICertificate; hostedZone: IHostedZone; -} +}; export class UiStack extends Stack { public readonly cloudFrontUrl: string; @@ -76,6 +86,7 @@ export class UiStack extends Stack { responsePagePath: '/index.html', ttl: Duration.seconds(30), }, + // TODO Do we want a custom page for 503, for when server is down? ], defaultBehavior: { origin: new S3Origin(hostBucket, { @@ -89,7 +100,6 @@ export class UiStack extends Stack { allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS, viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, }, - } ); @@ -97,7 +107,9 @@ export class UiStack extends Stack { const cloudFrontARecordName = generateResourceName('arecord-cfront'); new AaaaRecord(this, cloudFrontARecordName, { zone: hostedZone, - target: RecordTarget.fromAlias(new CloudFrontTarget(cloudFrontDistribution)), + target: RecordTarget.fromAlias( + new CloudFrontTarget(cloudFrontDistribution) + ), deleteExisting: true, comment: 'DNS AAAA Record for the UI host', }); From 54ccd390eef8d13e37ccb9c25ca91dbe7b2dabd4 Mon Sep 17 00:00:00 2001 From: Chris Wilton-Magras Date: Fri, 8 Mar 2024 14:22:06 +0000 Subject: [PATCH 3/5] Correct certs and DNS records --- cloud/README.md | 7 ++ cloud/bin/cloud.ts | 45 ++++++++---- cloud/cdk.context.json | 3 +- cloud/lib/api-stack.ts | 125 ++------------------------------- cloud/lib/certificate-stack.ts | 49 +++++++++++++ cloud/lib/hostedzone-stack.ts | 30 ++++++++ cloud/lib/index.ts | 4 +- cloud/lib/routing-stack.ts | 43 ------------ cloud/lib/scheduler-stack.ts | 114 ++++++++++++++++++++++++++++++ cloud/lib/ui-stack.ts | 27 ++++--- 10 files changed, 263 insertions(+), 184 deletions(-) create mode 100644 cloud/lib/certificate-stack.ts create mode 100644 cloud/lib/hostedzone-stack.ts delete mode 100644 cloud/lib/routing-stack.ts create mode 100644 cloud/lib/scheduler-stack.ts diff --git a/cloud/README.md b/cloud/README.md index 768394343..b293883b4 100644 --- a/cloud/README.md +++ b/cloud/README.md @@ -48,3 +48,10 @@ npm install # run the bootstrap command npx cdk bootstrap --custom-permissions-boundary cdk-developer-policy ``` + +Unless your default region is `us-east-1`, you will also need to bootstrap that region, as certificates for CloudFront +currently need to be deployed into that region: + +``` +npx cdk bootstrap --custom-permissions-boundary cdk-developer-policy aws://YOUR_ACCOUNT_NUMBER/us-east-1 +``` diff --git a/cloud/bin/cloud.ts b/cloud/bin/cloud.ts index 0ec8c1f59..3a912017e 100644 --- a/cloud/bin/cloud.ts +++ b/cloud/bin/cloud.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { App, Environment } from 'aws-cdk-lib'; +import { App, Environment } from 'aws-cdk-lib/core'; import 'source-map-support/register'; import { @@ -9,11 +9,16 @@ import { stackName, ApiStack, AuthStack, - RoutingStack, + CertificateStack, + HostedZoneStack, UiStack, } from '../lib'; const app = new App(); +const generateStackName = stackName(app); +const generateDescription = resourceDescription(app); + +/* Common stack resources */ const env: Environment = { account: process.env.CDK_DEFAULT_ACCOUNT, @@ -27,21 +32,35 @@ const tags = { 'keep-alive': '9-5-without-weekends', }; -const generateStackName = stackName(app); -const generateDescription = resourceDescription(app); +/* Stack constructs */ -const routingStack = new RoutingStack(app, generateStackName('routing'), { - description: generateDescription('Route 53 stack'), - env, - tags, -}); +const hostedZoneStack = new HostedZoneStack( + app, + generateStackName('hosted-zone'), + { + description: generateDescription('Hosted Zone stack'), + env, + tags, + } +); + +const certificateStack = new CertificateStack( + app, + generateStackName('certificate'), + { + description: generateDescription('Certificate stack'), + env, + tags, + hostedZone: hostedZoneStack.hostedZone, + } +); const uiStack = new UiStack(app, generateStackName('ui'), { description: generateDescription('UI stack'), env, tags, - certificate: routingStack.certificate, - hostedZone: routingStack.hostedZone, + certificate: certificateStack.cloudFrontCert, + hostedZone: hostedZoneStack.hostedZone, }); /*const authStack = */ new AuthStack(app, generateStackName('auth'), { @@ -55,8 +74,8 @@ new ApiStack(app, generateStackName('api'), { description: generateDescription('API stack'), env, tags, - certificate: routingStack.certificate, - hostedZone: routingStack.hostedZone, + certificate: certificateStack.loadBalancerCert, + hostedZone: hostedZoneStack.hostedZone, // userPool: authStack.userPool, // userPoolClient: authStack.userPoolClient, // userPoolDomain: authStack.userPoolDomain, diff --git a/cloud/cdk.context.json b/cloud/cdk.context.json index b8bdef4e3..dbd6a3c18 100644 --- a/cloud/cdk.context.json +++ b/cloud/cdk.context.json @@ -3,5 +3,6 @@ "eu-north-1a", "eu-north-1b", "eu-north-1c" - ] + ], + "ami:account=992382568770:filters.image-type.0=machine:filters.name.0=amzn-ami-vpc-nat-*:filters.state.0=available:owners.0=amazon:region=eu-north-1": "ami-072517490bf2cf3a3" } diff --git a/cloud/lib/api-stack.ts b/cloud/lib/api-stack.ts index a20d6f6a4..3eec6df08 100644 --- a/cloud/lib/api-stack.ts +++ b/cloud/lib/api-stack.ts @@ -17,36 +17,14 @@ import { import { ApplicationLoadBalancedFargateService } from 'aws-cdk-lib/aws-ecs-patterns'; //import { ListenerAction, ListenerCondition } from 'aws-cdk-lib/aws-elasticloadbalancingv2'; //import { AuthenticateCognitoAction } from 'aws-cdk-lib/aws-elasticloadbalancingv2-actions'; -import { - Effect, - PolicyStatement, - Role, - ServicePrincipal, -} from 'aws-cdk-lib/aws-iam'; -import { Runtime } from 'aws-cdk-lib/aws-lambda'; -import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; -import { ARecord, IHostedZone, RecordTarget } from 'aws-cdk-lib/aws-route53'; -import { LoadBalancerTarget } from 'aws-cdk-lib/aws-route53-targets'; +import { IHostedZone } from 'aws-cdk-lib/aws-route53'; import { Bucket } from 'aws-cdk-lib/aws-s3'; -import { - CronOptionsWithTimezone, - Group, - Schedule, - ScheduleExpression, - ScheduleTargetInput, -} from '@aws-cdk/aws-scheduler-alpha'; -import { LambdaInvoke } from '@aws-cdk/aws-scheduler-targets-alpha'; import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; -import { RemovalPolicy, Stack, StackProps, TimeZone } from 'aws-cdk-lib/core'; +import { RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib/core'; import { Construct } from 'constructs'; import { join } from 'node:path'; -import { - appName, - resourceDescription, - resourceName, -} from './resourceNamingUtils'; -import type { ServiceEventLambda } from './startStopServiceLambda'; +import { appName, resourceName } from './resourceNamingUtils'; type ApiStackProps = StackProps & { // userPool: UserPool; @@ -60,12 +38,11 @@ type ApiStackProps = StackProps & { export class ApiStack extends Stack { constructor(scope: Construct, id: string, props: ApiStackProps) { super(scope, id, props); - // TODO Enable cognito/JWT authorization + const { certificate, hostedZone, webappUrl } = props; const domainName = `api.${hostedZone.zoneName}`; const generateResourceName = resourceName(scope); - const generateResourceDescription = resourceDescription(scope); const dockerImageAsset = new DockerImageAsset( this, @@ -104,7 +81,7 @@ export class ApiStack extends Stack { { serviceName: fargateServiceName, cluster, - cpu: 256, // Default is 256 + cpu: 256, desiredCount: 1, taskImageOptions: { image: ContainerImage.fromDockerImageAsset(dockerImageAsset), @@ -126,10 +103,8 @@ export class ApiStack extends Stack { ), }, }, - memoryLimitMiB: 512, // Default is 512 + memoryLimitMiB: 512, loadBalancerName, - openListener: false, - publicLoadBalancer: true, certificate, domainName, domainZone: hostedZone, @@ -147,93 +122,7 @@ export class ApiStack extends Stack { }) ); - // DNS A Record for Route53 - const loadBalancerARecordName = generateResourceName('arecord-alb'); - new ARecord(this, loadBalancerARecordName, { - zone: hostedZone, - target: RecordTarget.fromAlias( - new LoadBalancerTarget(fargateService.loadBalancer) - ), - deleteExisting: true, - recordName: domainName, - comment: 'DNS A Record for the load-balanced API', - }); - - // Lambda to bring fargate service up or down - const startStopFunctionName = generateResourceName('fargate-switch'); - const startStopServiceFunction = new NodejsFunction( - this, - startStopFunctionName, - { - functionName: startStopFunctionName, - description: generateResourceDescription( - 'Fargate Service start/stop function' - ), - runtime: Runtime.NODEJS_18_X, - handler: 'handler', - entry: join(__dirname, './startStopServiceLambda.ts'), - bundling: { - minify: true, - }, - environment: { - CLUSTER_NAME: cluster.clusterName, - SERVICE_NAME: fargateService.service.serviceName, - }, - } - ); - startStopServiceFunction.addToRolePolicy( - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['ecs:DescribeServices', 'ecs:UpdateService'], - resources: [fargateService.service.serviceArn], - }) - ); - - // Schedule fargate service up at start of day, down at end - const schedulerRoleName = generateResourceName('scheduler-role'); - const schedulerRole = new Role(this, schedulerRoleName, { - roleName: schedulerRoleName, - assumedBy: new ServicePrincipal('scheduler.amazonaws.com'), - }); - const lambdaTarget = (operation: ServiceEventLambda['operation']) => - new LambdaInvoke(startStopServiceFunction, { - input: ScheduleTargetInput.fromObject({ operation }), - role: schedulerRole, - }); - const cronDef: CronOptionsWithTimezone = { - weekDay: 'MON-FRI', - minute: '0', - timeZone: TimeZone.EUROPE_LONDON, - }; - const scheduleGroupName = generateResourceName('fargate-scheduler-group'); - const scheduleGroup = new Group(this, scheduleGroupName, { - groupName: scheduleGroupName, - removalPolicy: RemovalPolicy.DESTROY, - }); - const serverUpScheduleName = generateResourceName('server-up'); - new Schedule(this, serverUpScheduleName, { - scheduleName: serverUpScheduleName, - description: generateResourceDescription('Scheduled server-up event'), - target: lambdaTarget('start'), - group: scheduleGroup, - schedule: ScheduleExpression.cron({ - ...cronDef, - hour: '9', - }), - }); - const serverDownScheduleName = generateResourceName('server-down'); - new Schedule(this, serverDownScheduleName, { - scheduleName: serverDownScheduleName, - description: generateResourceDescription('Scheduled server-down event'), - target: lambdaTarget('stop'), - group: scheduleGroup, - schedule: ScheduleExpression.cron({ - ...cronDef, - hour: '17', - }), - }); - - // TODO Hook up Cognito to load balancer, then remove API Gateway shenanigans + // TODO Hook up Cognito to load balancer! // https://stackoverflow.com/q/71124324 /* const authActionName = generateResourceName('alb-auth'); diff --git a/cloud/lib/certificate-stack.ts b/cloud/lib/certificate-stack.ts new file mode 100644 index 000000000..9f2a8e19a --- /dev/null +++ b/cloud/lib/certificate-stack.ts @@ -0,0 +1,49 @@ +import { + Certificate, + CertificateValidation, + DnsValidatedCertificate, + ICertificate, +} from 'aws-cdk-lib/aws-certificatemanager'; +import { IHostedZone } from 'aws-cdk-lib/aws-route53'; +import { Stack, StackProps } from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; + +import { resourceName } from './resourceNamingUtils'; + +type CertificateStackProps = StackProps & { + hostedZone: IHostedZone; +}; + +export class CertificateStack extends Stack { + public readonly cloudFrontCert: ICertificate; + public readonly loadBalancerCert: ICertificate; + + constructor(scope: Construct, id: string, props: CertificateStackProps) { + super(scope, id, props); + + const { hostedZone } = props; + const generateResourceName = resourceName(scope); + + const cloudFrontCertName = generateResourceName('certificate-cfront'); + // Yes this is deprecated, but CDK currently gives us no way to use + // Permissions Boundaries with cross-region resources, so ... + this.cloudFrontCert = new DnsValidatedCertificate( + this, + cloudFrontCertName, + { + certificateName: cloudFrontCertName, + domainName: hostedZone.zoneName, + hostedZone, + validation: CertificateValidation.fromDns(hostedZone), + region: 'us-east-1', + } + ); + + const loadBalancerCertName = generateResourceName('certificate-alb'); + this.loadBalancerCert = new Certificate(this, loadBalancerCertName, { + certificateName: loadBalancerCertName, + domainName: `api.${hostedZone.zoneName}`, + validation: CertificateValidation.fromDns(hostedZone), + }); + } +} diff --git a/cloud/lib/hostedzone-stack.ts b/cloud/lib/hostedzone-stack.ts new file mode 100644 index 000000000..6e0456d66 --- /dev/null +++ b/cloud/lib/hostedzone-stack.ts @@ -0,0 +1,30 @@ +import { HostedZone, IHostedZone } from 'aws-cdk-lib/aws-route53'; +import { Stack, StackProps } from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; + +import { resourceName } from './resourceNamingUtils'; + +export class HostedZoneStack extends Stack { + public readonly hostedZone: IHostedZone; + + constructor(scope: Construct, id: string, props: StackProps) { + super(scope, id, props); + + const { DOMAIN_NAME, HOSTED_ZONE_ID } = process.env; + if (!DOMAIN_NAME) { + throw new Error('DOMAIN_NAME not found in env vars'); + } + if (!HOSTED_ZONE_ID) { + throw new Error('HOSTED_ZONE_ID not found in env vars'); + } + + this.hostedZone = HostedZone.fromHostedZoneAttributes( + this, + resourceName(scope)('hosted-zone'), + { + hostedZoneId: HOSTED_ZONE_ID, + zoneName: DOMAIN_NAME, + } + ); + } +} diff --git a/cloud/lib/index.ts b/cloud/lib/index.ts index 32c0c196d..08aa5fd78 100644 --- a/cloud/lib/index.ts +++ b/cloud/lib/index.ts @@ -1,5 +1,7 @@ export * from './resourceNamingUtils'; export { ApiStack } from './api-stack'; export { AuthStack } from './auth-stack'; -export { RoutingStack } from './routing-stack'; +export { CertificateStack } from './certificate-stack'; +export { HostedZoneStack } from './hostedzone-stack'; +export { SchedulerStack } from './scheduler-stack'; export { UiStack } from './ui-stack'; diff --git a/cloud/lib/routing-stack.ts b/cloud/lib/routing-stack.ts deleted file mode 100644 index ed91cb659..000000000 --- a/cloud/lib/routing-stack.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - Certificate, - CertificateValidation, - ICertificate, -} from 'aws-cdk-lib/aws-certificatemanager'; -import { HostedZone, IHostedZone } from 'aws-cdk-lib/aws-route53'; -import { RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib/core'; -import { Construct } from 'constructs'; - -import { resourceName } from './resourceNamingUtils'; - -export class RoutingStack extends Stack { - public readonly certificate: ICertificate; - public readonly hostedZone: IHostedZone; - - constructor(scope: Construct, id: string, props: StackProps) { - super(scope, id, props); - - const generateResourceName = resourceName(scope); - - const { DOMAIN_NAME, HOSTED_ZONE_ID } = process.env; - if (!HOSTED_ZONE_ID) - throw new Error('HOSTED_ZONE_ID not found in env vars'); - if (!DOMAIN_NAME) throw new Error('DOMAIN_NAME not found in env vars'); - - this.hostedZone = HostedZone.fromHostedZoneAttributes( - this, - generateResourceName('hosted-zone'), - { - hostedZoneId: HOSTED_ZONE_ID, - zoneName: DOMAIN_NAME, - } - ); - this.hostedZone.applyRemovalPolicy(RemovalPolicy.RETAIN); - - const certificateName = generateResourceName('ssl-certificate'); - this.certificate = new Certificate(this, certificateName, { - certificateName, - domainName: DOMAIN_NAME, - validation: CertificateValidation.fromDns(this.hostedZone), - }); - } -} diff --git a/cloud/lib/scheduler-stack.ts b/cloud/lib/scheduler-stack.ts new file mode 100644 index 000000000..140348e4e --- /dev/null +++ b/cloud/lib/scheduler-stack.ts @@ -0,0 +1,114 @@ +import { ApplicationLoadBalancedFargateService } from 'aws-cdk-lib/aws-ecs-patterns'; +import { + Effect, + PolicyStatement, + Role, + ServicePrincipal, +} from 'aws-cdk-lib/aws-iam'; +import { Runtime } from 'aws-cdk-lib/aws-lambda'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import { + CronOptionsWithTimezone, + Group, + Schedule, + ScheduleExpression, + ScheduleTargetInput, +} from '@aws-cdk/aws-scheduler-alpha'; +import { LambdaInvoke } from '@aws-cdk/aws-scheduler-targets-alpha'; +import { RemovalPolicy, Stack, StackProps, TimeZone } from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; +import { join } from 'node:path'; + +import { resourceDescription, resourceName } from './resourceNamingUtils'; +import { ServiceEventLambda } from './startStopServiceLambda'; + +type SchedulerStackProps = StackProps & { + fargateService: ApplicationLoadBalancedFargateService; +}; + +export class SchedulerStack extends Stack { + constructor(scope: Construct, id: string, props: SchedulerStackProps) { + super(scope, id, props); + + const { fargateService } = props; + const generateResourceName = resourceName(scope); + const generateResourceDescription = resourceDescription(scope); + + // Lambda to bring fargate service up and down + const startStopFunctionName = generateResourceName('fargate-switch'); + const startStopServiceFunction = new NodejsFunction( + this, + startStopFunctionName, + { + functionName: startStopFunctionName, + description: generateResourceDescription( + 'Fargate Service start/stop function' + ), + runtime: Runtime.NODEJS_18_X, + handler: 'handler', + entry: join(__dirname, './startStopServiceLambda.ts'), + bundling: { + minify: true, + }, + environment: { + CLUSTER_NAME: fargateService.cluster.clusterName, + SERVICE_NAME: fargateService.service.serviceName, + }, + } + ); + startStopServiceFunction.addToRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['ecs:DescribeServices', 'ecs:UpdateService'], + resources: [fargateService.service.serviceArn], + }) + ); + + // Schedule fargate service up at start of day, down at end + const schedulerRoleName = generateResourceName('scheduler-role'); + const schedulerRole = new Role(this, schedulerRoleName, { + roleName: schedulerRoleName, + assumedBy: new ServicePrincipal('scheduler.amazonaws.com'), + }); + const lambdaTarget = (operation: ServiceEventLambda['operation']) => + new LambdaInvoke(startStopServiceFunction, { + input: ScheduleTargetInput.fromObject({ operation }), + role: schedulerRole, + }); + const cronDef: CronOptionsWithTimezone = { + weekDay: 'MON-FRI', + minute: '0', + timeZone: TimeZone.EUROPE_LONDON, + }; + + const scheduleGroupName = generateResourceName('fargate-scheduler-group'); + const scheduleGroup = new Group(this, scheduleGroupName, { + groupName: scheduleGroupName, + removalPolicy: RemovalPolicy.DESTROY, + }); + + const serverUpScheduleName = generateResourceName('server-up'); + new Schedule(this, serverUpScheduleName, { + scheduleName: serverUpScheduleName, + description: generateResourceDescription('Scheduled server-up event'), + target: lambdaTarget('start'), + group: scheduleGroup, + schedule: ScheduleExpression.cron({ + ...cronDef, + hour: '8', + }), + }); + + const serverDownScheduleName = generateResourceName('server-down'); + new Schedule(this, serverDownScheduleName, { + scheduleName: serverDownScheduleName, + description: generateResourceDescription('Scheduled server-down event'), + target: lambdaTarget('stop'), + group: scheduleGroup, + schedule: ScheduleExpression.cron({ + ...cronDef, + hour: '18', + }), + }); + } +} diff --git a/cloud/lib/ui-stack.ts b/cloud/lib/ui-stack.ts index 7db40af7e..7640f38ef 100644 --- a/cloud/lib/ui-stack.ts +++ b/cloud/lib/ui-stack.ts @@ -17,7 +17,12 @@ import { StackProps, } from 'aws-cdk-lib/core'; import * as iam from 'aws-cdk-lib/aws-iam'; -import { AaaaRecord, IHostedZone, RecordTarget } from 'aws-cdk-lib/aws-route53'; +import { + AaaaRecord, + ARecord, + IHostedZone, + RecordTarget, +} from 'aws-cdk-lib/aws-route53'; import { CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets'; import { BlockPublicAccess, @@ -103,18 +108,24 @@ export class UiStack extends Stack { } ); - // DNS AAAA Record for Route53 - const cloudFrontARecordName = generateResourceName('arecord-cfront'); - new AaaaRecord(this, cloudFrontARecordName, { + // DNS Records for Route53 + const target = RecordTarget.fromAlias( + new CloudFrontTarget(cloudFrontDistribution) + ); + new ARecord(this, generateResourceName('a-record-cfront'), { + zone: hostedZone, + target, + deleteExisting: true, + comment: 'DNS A Record for the UI host', + }); + new AaaaRecord(this, generateResourceName('aaaa-record-cfront'), { zone: hostedZone, - target: RecordTarget.fromAlias( - new CloudFrontTarget(cloudFrontDistribution) - ), + target, deleteExisting: true, comment: 'DNS AAAA Record for the UI host', }); - this.cloudFrontUrl = `https://${cloudFrontDistribution.domainName}`; + this.cloudFrontUrl = `https://${hostedZone.zoneName}`; new CfnOutput(this, 'WebURL', { value: this.cloudFrontUrl, }); From 955ad5ffb91afba2f8ff88ae4bf7b4f9c9105669 Mon Sep 17 00:00:00 2001 From: Chris Wilton-Magras Date: Fri, 8 Mar 2024 15:26:58 +0000 Subject: [PATCH 4/5] Revert workaround for Forwarded headers, trust proxy again --- backend/src/app/app.ts | 3 +-- backend/src/app/sessionRoutes.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/src/app/app.ts b/backend/src/app/app.ts index 2166e4cb1..7bf9a1c21 100644 --- a/backend/src/app/app.ts +++ b/backend/src/app/app.ts @@ -3,10 +3,9 @@ import express from 'express'; import queryTypes from 'query-types'; import nonSessionRoutes from './nonSessionRoutes'; -import { usingForwardedHeader } from './proxySetup'; import sessionRoutes from './sessionRoutes'; -export default usingForwardedHeader(express()) +export default express() .use(express.json()) .use(queryTypes.middleware()) .use( diff --git a/backend/src/app/sessionRoutes.ts b/backend/src/app/sessionRoutes.ts index 6e6ebf256..2ba289010 100644 --- a/backend/src/app/sessionRoutes.ts +++ b/backend/src/app/sessionRoutes.ts @@ -57,10 +57,10 @@ const router = express }), cookie: { maxAge, - partitioned: isProd, - sameSite: isProd ? 'none' : 'strict', + sameSite: 'strict', secure: isProd, }, + proxy: true, }) ) .use((req, _res, next) => { From 2b9cbfa26dff03b50399066de9d7c1bcca06e655 Mon Sep 17 00:00:00 2001 From: Chris Wilton-Magras Date: Fri, 8 Mar 2024 16:42:02 +0000 Subject: [PATCH 5/5] Fix backend tests --- backend/test/api/start.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/test/api/start.test.ts b/backend/test/api/start.test.ts index bef928f59..7b7987f0d 100644 --- a/backend/test/api/start.test.ts +++ b/backend/test/api/start.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it, jest } from '@jest/globals'; import { OpenAI } from 'openai'; import request from 'supertest'; -import app from '@src/app'; +import app from '@src/app/app'; import { StartResponse } from '@src/models/api/StartGetRequest'; import { LEVEL_NAMES } from '@src/models/level';