diff --git a/cloud/bin/cloud.ts b/cloud/bin/cloud.ts index 3903f9dd9..efd879b6f 100644 --- a/cloud/bin/cloud.ts +++ b/cloud/bin/cloud.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { App } from 'aws-cdk-lib'; +import { App, Environment } from 'aws-cdk-lib'; import 'source-map-support/register'; import { @@ -12,6 +12,12 @@ import { } from '../lib'; const app = new App(); + +const awsEnv = (): Environment => ({ + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION, +}); + const tags = { owner: appName, classification: 'unrestricted', @@ -24,22 +30,25 @@ const generateStackName = stackName(app); const generateDescription = resourceDescription(app); const uiStack = new UiStack(app, generateStackName('ui'), { - tags, description: generateDescription('UI stack'), + env: awsEnv(), + tags, }); // Don't need this stack yet. /* const authStack = new AuthStack(app, generateStackName('auth'), { - tags, description: generateDescription('Auth stack'), + env: awsEnv(), + tags, webappUrl: uiStack.cloudfrontUrl, }); */ new ApiStack(app, generateStackName('api'), { - tags, description: generateDescription('API stack'), + env: awsEnv(), + tags, // userPool: authStack.userPool, // userPoolClient: authStack.userPoolClient, // userPoolDomain: authStack.userPoolDomain, diff --git a/cloud/cdk.context.json b/cloud/cdk.context.json new file mode 100644 index 000000000..1a2f60309 --- /dev/null +++ b/cloud/cdk.context.json @@ -0,0 +1,7 @@ +{ + "availability-zones:account=600982866784:region=eu-west-1": [ + "eu-west-1a", + "eu-west-1b", + "eu-west-1c" + ] +} diff --git a/cloud/lib/api-stack.ts b/cloud/lib/api-stack.ts index ecae28be2..c1dd85429 100644 --- a/cloud/lib/api-stack.ts +++ b/cloud/lib/api-stack.ts @@ -1,20 +1,19 @@ +import { CorsHttpMethod, HttpApi, VpcLink } from 'aws-cdk-lib/aws-apigatewayv2'; +import { HttpAlbIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations'; //import { UserPool, UserPoolClient, UserPoolDomain } from 'aws-cdk-lib/aws-cognito'; -import { Vpc } from 'aws-cdk-lib/aws-ec2'; +import { Port, SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2'; import { DockerImageAsset } from 'aws-cdk-lib/aws-ecr-assets'; -import { - Cluster, - ContainerImage, - Secret as EnvSecret, -} from 'aws-cdk-lib/aws-ecs'; +import { Cluster, ContainerImage, PropagatedTagSource, 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 { AuthenticateCognitoAction } from 'aws-cdk-lib/aws-elasticloadbalancingv2-actions'; +import { Bucket } from 'aws-cdk-lib/aws-s3'; import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; -import { Stack, StackProps } from 'aws-cdk-lib/core'; +import { CfnOutput, RemovalPolicy, Stack, StackProps, Tags } from 'aws-cdk-lib/core'; import { Construct } from 'constructs'; import { join } from 'node:path'; -import { resourceName } from './resourceNamingUtils'; +import { resourceDescription, resourceName } from './resourceNamingUtils'; type ApiStackProps = StackProps & { // userPool: UserPool; @@ -24,12 +23,15 @@ type ApiStackProps = StackProps & { }; export class ApiStack extends Stack { + //public readonly loadBalancerUrl: string; + constructor(scope: Construct, id: string, props: ApiStackProps) { super(scope, id, props); // TODO Enable cognito auth const { /*userPool, userPoolClient, userPoolDomain,*/ webappUrl } = props; const generateResourceName = resourceName(scope); + const generateResourceDescription = resourceDescription(scope); const dockerImageAsset = new DockerImageAsset( this, @@ -41,7 +43,11 @@ 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, + restrictDefaultSecurityGroup: false, // TODO blog about this! + maxAzs: 2, + }); const clusterName = generateResourceName('cluster'); const cluster = new Cluster(this, clusterName, { clusterName, vpc }); @@ -51,15 +57,16 @@ export class ApiStack extends Stack { 'dev/SpyLogic/ApiKey' ); - // Create a load-balanced Fargate service and make it public + // Create a private, application-load-balanced Fargate service const containerPort = 3001; - const serviceName = generateResourceName('fargate'); - const loadBalancerName = generateResourceName('alb'); + const fargateServiceName = generateResourceName('fargate'); + const loadBalancerName = generateResourceName('loadbalancer'); + const loadBalancerLogName = generateResourceName('loadbalancer-logs'); const fargateService = new ApplicationLoadBalancedFargateService( this, - serviceName, + fargateServiceName, { - serviceName, + serviceName: fargateServiceName, cluster, cpu: 256, // Default is 256 desiredCount: 1, // Bump this up for prod! @@ -84,13 +91,25 @@ export class ApiStack extends Stack { }, memoryLimitMiB: 512, // Default is 512 loadBalancerName, - publicLoadBalancer: true, // Default is true + publicLoadBalancer: false, + propagateTags: PropagatedTagSource.SERVICE, } ); + fargateService.targetGroup.configureHealthCheck({ + path: '/health', + }); + fargateService.loadBalancer.logAccessLogs( + new Bucket(this, loadBalancerLogName, { + bucketName: loadBalancerLogName, + autoDeleteObjects: true, + removalPolicy: RemovalPolicy.DESTROY, + }) + ); + //this.loadBalancerUrl = `http://${fargateService.loadBalancer.loadBalancerDnsName}`; // Hook up Cognito to load balancer // https://stackoverflow.com/q/71124324 - // TODO This needs HTTPS and a Route53 domain, so in meantime try VPCLink: + // 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'); @@ -104,6 +123,48 @@ export class ApiStack extends Stack { }); */ - fargateService.targetGroup.configureHealthCheck({ path: '/health' }); + // 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], // TODO Mention this in blog! ANY does not appear to work... + 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/package-lock.json b/cloud/package-lock.json index fe3850df4..c1b2a5aaa 100644 --- a/cloud/package-lock.json +++ b/cloud/package-lock.json @@ -8,7 +8,7 @@ "name": "cloud", "version": "0.1.0", "dependencies": { - "aws-cdk-lib": "^2.104.0", + "aws-cdk-lib": "^2.117.0", "constructs": "^10.3.0", "dotenv": "^16.3.1", "source-map-support": "^0.5.21" @@ -21,7 +21,7 @@ "@types/source-map-support": "^0.5.9", "@typescript-eslint/eslint-plugin": "^6.9.0", "@typescript-eslint/parser": "^6.9.0", - "aws-cdk": "^2.104.0", + "aws-cdk": "^2.117.0", "concurrently": "^8.2.2", "eslint": "^8.52.0", "prettier": "^3.0.3", @@ -567,9 +567,9 @@ } }, "node_modules/aws-cdk": { - "version": "2.104.0", - "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.104.0.tgz", - "integrity": "sha512-JuCafR5D1lnMKA88JUYhvRYeguozAWneC/n6kR1FUG+kXtXxpEqOxP91118dfJZYRw7FMIkHW8ewddvLwaCy5g==", + "version": "2.117.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.117.0.tgz", + "integrity": "sha512-uuWT646vSRXZ/6don+wfK4kelV1aL4WOTduaihltRlXw4etHoMV3wJYBO30E6e8hAU+0HkLT7Fv58po50b12Sg==", "dev": true, "bin": { "cdk": "bin/cdk" @@ -582,9 +582,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.104.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.104.0.tgz", - "integrity": "sha512-gD5KD2j8C5ff7j4RTA5ajFDNnpw4EvVhWMgWWrOyIEa9OkndEXwzhvCvwbEpwmgSUvnmsmbiHTBaLg8KVP+yKA==", + "version": "2.117.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.117.0.tgz", + "integrity": "sha512-My5T4hn34H6+tnZxKK2VlcvGI2N5SjqGt9lXWADcahdobuUcNizYrls7h/vcQ3BfwcZ5/tHTKtivkNyL8I1LDg==", "bundleDependencies": [ "@balena/dockerignore", "case", @@ -598,16 +598,16 @@ "yaml" ], "dependencies": { - "@aws-cdk/asset-awscli-v1": "^2.2.200", + "@aws-cdk/asset-awscli-v1": "^2.2.201", "@aws-cdk/asset-kubectl-v20": "^2.1.2", "@aws-cdk/asset-node-proxy-agent-v6": "^2.0.1", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", - "fs-extra": "^11.1.1", - "ignore": "^5.2.4", + "fs-extra": "^11.2.0", + "ignore": "^5.3.0", "jsonschema": "^1.4.1", "minimatch": "^3.1.2", - "punycode": "^2.3.0", + "punycode": "^2.3.1", "semver": "^7.5.4", "table": "^6.8.1", "yaml": "1.10.2" @@ -723,7 +723,7 @@ "license": "MIT" }, "node_modules/aws-cdk-lib/node_modules/fs-extra": { - "version": "11.1.1", + "version": "11.2.0", "inBundle": true, "license": "MIT", "dependencies": { @@ -741,7 +741,7 @@ "license": "ISC" }, "node_modules/aws-cdk-lib/node_modules/ignore": { - "version": "5.2.4", + "version": "5.3.0", "inBundle": true, "license": "MIT", "engines": { @@ -808,7 +808,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/punycode": { - "version": "2.3.0", + "version": "2.3.1", "inBundle": true, "license": "MIT", "engines": { @@ -893,7 +893,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/universalify": { - "version": "2.0.0", + "version": "2.0.1", "inBundle": true, "license": "MIT", "engines": { diff --git a/cloud/package.json b/cloud/package.json index 871a4dd4e..086619cff 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -6,7 +6,7 @@ }, "scripts": { "cdk:synth": "cdk synth -q \"*\"", - "cdk:deploy": "cdk deploy --all", + "cdk:deploy": "cdk deploy --app cdk.out --all", "cdk:destroy": "cdk destroy --all", "codecheck": "concurrently \"npm run lint:check\" \"npm run format:check\"", "format": "prettier . --write", @@ -21,13 +21,13 @@ "@typescript-eslint/parser": "^6.9.0", "concurrently": "^8.2.2", "eslint": "^8.52.0", - "aws-cdk": "^2.104.0", + "aws-cdk": "^2.117.0", "prettier": "^3.0.3", "ts-node": "^10.9.1", "typescript": "^5.2.2" }, "dependencies": { - "aws-cdk-lib": "^2.104.0", + "aws-cdk-lib": "^2.117.0", "constructs": "^10.3.0", "dotenv": "^16.3.1", "source-map-support": "^0.5.21"