diff --git a/cdk/bin/cdk.ts b/cdk/bin/cdk.ts index 8b80bfc..fbad95b 100644 --- a/cdk/bin/cdk.ts +++ b/cdk/bin/cdk.ts @@ -4,5 +4,9 @@ import { GhostV2Stack } from '../lib/cdk-stack'; const app = new cdk.App(); new GhostV2Stack(app, "GhostV2Stack-dev", { - env: { region: "ca-central-1", account: process.env.AWS_ACCOUNT_ID! }, + env: { region: "ca-central-1", account: process.env.AWS_ACCOUNT_ID! }, environment: "dev" }); + +new GhostV2Stack(app, "GhostV2Stack-prod", { + env: { region: "ca-central-1", account: process.env.AWS_ACCOUNT_ID!, }, environment: "prod" +}) diff --git a/cdk/cdk.context.json b/cdk/cdk.context.json new file mode 100644 index 0000000..72f7b8a --- /dev/null +++ b/cdk/cdk.context.json @@ -0,0 +1,7 @@ +{ + "availability-zones:account=585061171043:region=ca-central-1": [ + "ca-central-1a", + "ca-central-1b", + "ca-central-1d" + ] +} diff --git a/cdk/lib/cdk-stack.ts b/cdk/lib/cdk-stack.ts index 8a03114..c8a0056 100644 --- a/cdk/lib/cdk-stack.ts +++ b/cdk/lib/cdk-stack.ts @@ -1,20 +1,156 @@ -import * as cdk from 'aws-cdk-lib/core'; -import { Construct } from 'constructs'; +import * as cdk from "aws-cdk-lib/core"; +import { Construct } from "constructs"; import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import * as ecs from "aws-cdk-lib/aws-ecs"; +import * as ssm from "aws-cdk-lib/aws-ssm" +import * as servicediscovery from "aws-cdk-lib/aws-servicediscovery"; +import * as apigatewayv2 from "aws-cdk-lib/aws-apigatewayv2"; +import * as path from "path"; + +interface StackProps extends cdk.StackProps { + environment: string +} export class GhostV2Stack extends cdk.Stack { - constructor(scope: Construct, id: string, props?: cdk.StackProps) { + constructor(scope: Construct, id: string, props: StackProps) { super(scope, id, props); const table = new dynamodb.TableV2(this, "ghostv2-table", { partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING }, sortKey: { name: "sk", type: dynamodb.AttributeType.STRING }, billing: dynamodb.Billing.onDemand(), - }) + }); table.addGlobalSecondaryIndex({ indexName: "gsi1", partitionKey: { name: "gsi1pk", type: dynamodb.AttributeType.STRING }, - sortKey: { name: "gsi1sk", type: dynamodb.AttributeType.STRING } - }) + sortKey: { name: "gsi1sk", type: dynamodb.AttributeType.STRING }, + }); + + const vpc = new ec2.Vpc(this, "ghostv2 vpc", { + maxAzs: 2, + natGateways: 0, + subnetConfiguration: [ + { + name: "Public", + subnetType: ec2.SubnetType.PUBLIC, + cidrMask: 24, + }, + ], + }); + + const ecsSecurityGroup = new ec2.SecurityGroup(this, "ghostv2 security group", { + vpc, + description: "Security group for ECS Fargate tasks", + allowAllOutbound: true, + }); + ecsSecurityGroup.addIngressRule( + ec2.Peer.ipv4(vpc.vpcCidrBlock), + ec2.Port.tcp(8080), + "Allow traffic from VPC" + ); + + const cluster = new ecs.Cluster(this, "ghostv2 cluster", { + vpc, + }); + const namespace = new servicediscovery.PrivateDnsNamespace( + this, + "ghostv2 namespace", + { + name: "ghostv2.local", + vpc, + } + ); + const taskDefinition = new ecs.FargateTaskDefinition( + this, + "ghostv2 api", + { + memoryLimitMiB: 512, + cpu: 256, + runtimePlatform: { + cpuArchitecture: ecs.CpuArchitecture.X86_64, + operatingSystemFamily: ecs.OperatingSystemFamily.LINUX, + }, + } + ); + table.grantReadWriteData(taskDefinition.taskRole); + + const secretImports = ["DATABASE_URL", "GH_CLIENT_ID", "GH_CLIENT_SECRET", "REPOS_TABLE_NAME", "SENTRY_DSN"]; + const secrets = secretImports.reduce>((acc, name) => { + acc[name] = ecs.Secret.fromSsmParameter(ssm.StringParameter.fromSecureStringParameterAttributes(this, `param${name}`, { + parameterName: `/ghostv2/${props.environment}/${name}` + })) + return acc + }, {}) + taskDefinition.addContainer("api", { + image: ecs.ContainerImage.fromAsset(path.join(__dirname, "../../api")), + environment: { + RUST_LOG: "info", + }, + portMappings: [ + { + containerPort: 8080, + protocol: ecs.Protocol.TCP, + }, + ], + secrets + }); + + const service = new ecs.FargateService(this, "ghostv2 service", { + cluster, + taskDefinition, + desiredCount: 1, + assignPublicIp: true, + vpcSubnets: { + subnetType: ec2.SubnetType.PUBLIC, + }, + securityGroups: [ecsSecurityGroup], + capacityProviderStrategies: [ + { + capacityProvider: "FARGATE_SPOT", + weight: 1, + }, + ], + cloudMapOptions: { + name: "api", + cloudMapNamespace: namespace, + dnsRecordType: servicediscovery.DnsRecordType.SRV, + containerPort: 8080, + dnsTtl: cdk.Duration.seconds(10), + }, + }); + + const vpcLink = new apigatewayv2.CfnVpcLink(this, "ghostv2 vpc link", { + name: "ghostv2-vpc-link", + subnetIds: vpc.publicSubnets.map((s) => s.subnetId), + securityGroupIds: [ecsSecurityGroup.securityGroupId], + }); + + const httpApi = new apigatewayv2.CfnApi(this, "ghostv2 http-api", { + name: "ghostv2-api", + protocolType: "HTTP", + }); + + const integration = new apigatewayv2.CfnIntegration(this, "Integration", { + apiId: httpApi.ref, + integrationType: "HTTP_PROXY", + integrationMethod: "ANY", + integrationUri: service.cloudMapService!.serviceArn, + connectionType: "VPC_LINK", + connectionId: vpcLink.ref, + payloadFormatVersion: "1.0", + }); + + new apigatewayv2.CfnRoute(this, "DefaultRoute", { + apiId: httpApi.ref, + routeKey: "$default", + target: `integrations/${integration.ref}`, + }); + + new apigatewayv2.CfnStage(this, "Stage", { + apiId: httpApi.ref, + stageName: "$default", + autoDeploy: true, + }); } } diff --git a/scripts/import-env.sh b/scripts/import-env.sh new file mode 100755 index 0000000..a90b00c --- /dev/null +++ b/scripts/import-env.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +PREFIX="/ghostv2" +REGION="ca-central-1" +WRITE="$1" + +while getopts "w" opt; do + case $opt in + w) WRITE=true ;; + *) echo "Usage $0 [-w]" && exit 1 ;; + esac +done + +while IFS='=' read -r key value; do + [[ -z "$key" || "$key" =~ ^# ]] && continue + + echo "Creating parameter: $PREFIX/$key" + + if [[ "$WRITE" == "true" ]]; then + aws ssm put-parameter \ + --name "$PREFIX/$key" \ + --value "$value" \ + --type SecureString \ + --overwrite \ + --region "$REGION" > /dev/null + fi +done < api/.env