From 0136b74ee0d244b46a863e7023daea8a516bd1e7 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:18:07 -0500 Subject: [PATCH] infrastructure with aws cdk --- infrastructure/cdk/bin/app.ts | 176 ++++++- infrastructure/cdk/lib/certificate-stack.ts | 59 +++ infrastructure/cdk/lib/ecr-stack.ts | 90 ++++ infrastructure/cdk/lib/ecs-service-stack.ts | 483 ++++++++++++++++++++ infrastructure/cdk/lib/rds-stack.ts | 174 +++++++ infrastructure/cdk/lib/secrets-stack.ts | 87 ++++ 6 files changed, 1049 insertions(+), 20 deletions(-) create mode 100644 infrastructure/cdk/lib/certificate-stack.ts create mode 100644 infrastructure/cdk/lib/ecr-stack.ts create mode 100644 infrastructure/cdk/lib/ecs-service-stack.ts create mode 100644 infrastructure/cdk/lib/rds-stack.ts create mode 100644 infrastructure/cdk/lib/secrets-stack.ts diff --git a/infrastructure/cdk/bin/app.ts b/infrastructure/cdk/bin/app.ts index 2563f8f..41fb73a 100644 --- a/infrastructure/cdk/bin/app.ts +++ b/infrastructure/cdk/bin/app.ts @@ -4,11 +4,20 @@ import * as cdk from "aws-cdk-lib"; import { ConditionCheckLambdaStack } from "../lib/condition-check-lambda-stack"; import { ImageProcessorLambdaStack } from "../lib/image-processor-lambda-stack"; import { VpcStack } from "../lib/vpc-stack"; +import { CertificateStack } from "../lib/certificate-stack"; +import { SecretsStack } from "../lib/secrets-stack"; +import { EcrStack } from "../lib/ecr-stack"; +import { RdsStack } from "../lib/rds-stack"; +import { EcsServiceStack } from "../lib/ecs-service-stack"; const app = new cdk.App(); -// Get environment from context or default to staging -const environment = app.node.tryGetContext("env") || "staging"; +// Get environment from context or default to dev +const environment = app.node.tryGetContext("env") || "dev"; + +// Get context variables for deployment configuration +const allowedIp = app.node.tryGetContext("allowedIp"); // e.g., "1.2.3.4/32" +const domainName = app.node.tryGetContext("domainName") || "village-share.com"; // Environment-specific configurations const envConfig: Record< @@ -19,26 +28,46 @@ const envConfig: Record< sesFromEmail: string; emailEnabled: boolean; natGateways: number; + subdomain: string; + dbInstanceType: "micro" | "small" | "medium"; + multiAz: boolean; } > = { + dev: { + databaseUrl: + process.env.DATABASE_URL || + "postgresql://user:password@localhost:5432/rentall_dev", + frontendUrl: `https://dev.${domainName}`, + sesFromEmail: `noreply@${domainName}`, + emailEnabled: false, // Disable emails in dev + natGateways: 1, + subdomain: "dev", + dbInstanceType: "micro", + multiAz: false, + }, staging: { - // These should be passed via CDK context or SSM parameters in production databaseUrl: process.env.DATABASE_URL || "postgresql://user:password@localhost:5432/rentall_staging", - frontendUrl: "https://staging.villageshare.app", - sesFromEmail: "noreply@villageshare.app", + frontendUrl: `https://staging.${domainName}`, + sesFromEmail: `noreply@${domainName}`, emailEnabled: true, - natGateways: 1, // Single NAT gateway for cost optimization in staging + natGateways: 1, + subdomain: "staging", + dbInstanceType: "micro", + multiAz: false, }, prod: { databaseUrl: process.env.DATABASE_URL || "postgresql://user:password@localhost:5432/rentall_prod", - frontendUrl: "https://villageshare.app", - sesFromEmail: "noreply@villageshare.app", + frontendUrl: `https://${domainName}`, + sesFromEmail: `noreply@${domainName}`, emailEnabled: true, - natGateways: 2, // Multi-AZ NAT gateways for high availability in production + natGateways: 2, // Multi-AZ NAT gateways for high availability + subdomain: "", // No subdomain for prod + dbInstanceType: "small", + multiAz: true, }, }; @@ -55,7 +84,30 @@ const envProps = { }, }; -// Create the VPC stack first (other stacks depend on it) +// Common tags for all stacks +const commonTags = { + Environment: environment, + Project: "village-share", + ManagedBy: "cdk", +}; + +// ============================================================================ +// Certificate Stack (Shared across environments) +// Deploy this once and validate DNS before deploying other stacks +// ============================================================================ +const certificateStack = new CertificateStack(app, "CertificateStack", { + domainName, + ...envProps, + description: `ACM wildcard certificate for ${domainName}`, + tags: { + ...commonTags, + Service: "certificate", + }, +}); + +// ============================================================================ +// VPC Stack +// ============================================================================ const vpcStack = new VpcStack(app, `VpcStack-${environment}`, { environment, natGateways: config.natGateways, @@ -63,13 +115,102 @@ const vpcStack = new VpcStack(app, `VpcStack-${environment}`, { ...envProps, description: `VPC infrastructure with private subnets and VPC endpoints (${environment})`, tags: { - Environment: environment, - Project: "village-share", + ...commonTags, Service: "networking", }, }); -// Create the Condition Check Lambda stack +// ============================================================================ +// Secrets Stack +// ============================================================================ +const secretsStack = new SecretsStack(app, `SecretsStack-${environment}`, { + environment, + ...envProps, + description: `Secrets Manager secrets for database and application (${environment})`, + tags: { + ...commonTags, + Service: "secrets", + }, +}); + +// ============================================================================ +// ECR Stack +// ============================================================================ +const ecrStack = new EcrStack(app, `EcrStack-${environment}`, { + environment, + ...envProps, + description: `ECR repositories for Docker images (${environment})`, + tags: { + ...commonTags, + Service: "ecr", + }, +}); + +// ============================================================================ +// RDS Stack +// ============================================================================ +const rdsStack = new RdsStack(app, `RdsStack-${environment}`, { + environment, + vpc: vpcStack.vpc, + databaseSecret: secretsStack.databaseSecret, + databaseName: "rentall", + multiAz: config.multiAz, + ...envProps, + description: `RDS PostgreSQL database (${environment})`, + tags: { + ...commonTags, + Service: "database", + }, +}); + +// RDS depends on VPC and Secrets +rdsStack.addDependency(vpcStack); +rdsStack.addDependency(secretsStack); + +// ============================================================================ +// ECS Service Stack +// ============================================================================ +const fullDomainName = config.subdomain + ? `${config.subdomain}.${domainName}` + : domainName; + +const ecsServiceStack = new EcsServiceStack( + app, + `EcsServiceStack-${environment}`, + { + environment, + vpc: vpcStack.vpc, + certificate: certificateStack.certificate, + backendRepository: ecrStack.backendRepository, + frontendRepository: ecrStack.frontendRepository, + databaseSecret: secretsStack.databaseSecret, + appSecret: secretsStack.appSecret, + databaseSecurityGroup: rdsStack.databaseSecurityGroup, + dbEndpoint: rdsStack.dbEndpoint, + dbPort: rdsStack.dbPort, + dbName: "rentall", + domainName: fullDomainName, + allowedIp: environment === "dev" ? allowedIp : undefined, // Only restrict in dev + frontendUrl: config.frontendUrl, + ...envProps, + description: `ECS Fargate services with ALB (${environment})`, + tags: { + ...commonTags, + Service: "ecs", + }, + } +); + +// ECS depends on VPC, Certificate, ECR, Secrets, and RDS +ecsServiceStack.addDependency(vpcStack); +ecsServiceStack.addDependency(certificateStack); +ecsServiceStack.addDependency(ecrStack); +ecsServiceStack.addDependency(secretsStack); +ecsServiceStack.addDependency(rdsStack); + +// ============================================================================ +// Lambda Stacks (existing) +// ============================================================================ const conditionCheckStack = new ConditionCheckLambdaStack( app, `ConditionCheckLambdaStack-${environment}`, @@ -84,17 +225,14 @@ const conditionCheckStack = new ConditionCheckLambdaStack( ...envProps, description: `Condition Check Reminder Lambda infrastructure (${environment})`, tags: { - Environment: environment, - Project: "village-share", + ...commonTags, Service: "condition-check-reminder", }, } ); -// Add dependency on VPC stack conditionCheckStack.addDependency(vpcStack); -// Create the Image Processor Lambda stack const imageProcessorStack = new ImageProcessorLambdaStack( app, `ImageProcessorLambdaStack-${environment}`, @@ -107,12 +245,10 @@ const imageProcessorStack = new ImageProcessorLambdaStack( ...envProps, description: `Image Processor Lambda infrastructure (${environment})`, tags: { - Environment: environment, - Project: "village-share", + ...commonTags, Service: "image-processor", }, } ); -// Add dependency on VPC stack imageProcessorStack.addDependency(vpcStack); diff --git a/infrastructure/cdk/lib/certificate-stack.ts b/infrastructure/cdk/lib/certificate-stack.ts new file mode 100644 index 0000000..d9853e5 --- /dev/null +++ b/infrastructure/cdk/lib/certificate-stack.ts @@ -0,0 +1,59 @@ +import * as cdk from "aws-cdk-lib"; +import * as acm from "aws-cdk-lib/aws-certificatemanager"; +import { Construct } from "constructs"; + +interface CertificateStackProps extends cdk.StackProps { + /** + * The domain name for the certificate (e.g., village-share.com) + */ + domainName: string; +} + +export class CertificateStack extends cdk.Stack { + /** + * The ACM certificate for the domain + */ + public readonly certificate: acm.Certificate; + + /** + * The certificate ARN for cross-stack references + */ + public readonly certificateArn: string; + + constructor(scope: Construct, id: string, props: CertificateStackProps) { + super(scope, id, props); + + const { domainName } = props; + + // Create wildcard certificate for the domain + // This covers both the apex domain and all subdomains + this.certificate = new acm.Certificate(this, "WildcardCertificate", { + domainName: domainName, + subjectAlternativeNames: [`*.${domainName}`], + validation: acm.CertificateValidation.fromDns(), + certificateName: `${domainName}-wildcard`, + }); + + this.certificateArn = this.certificate.certificateArn; + + // Outputs + new cdk.CfnOutput(this, "CertificateArn", { + value: this.certificate.certificateArn, + description: "ACM Certificate ARN", + exportName: `CertificateArn-${domainName.replace(/\./g, "-")}`, + }); + + new cdk.CfnOutput(this, "DomainName", { + value: domainName, + description: "Domain name for the certificate", + }); + + // Important: After deployment, you need to add CNAME records to your DNS provider + // Run: aws acm describe-certificate --certificate-arn --query 'Certificate.DomainValidationOptions' + // to get the CNAME records needed for DNS validation + new cdk.CfnOutput(this, "ValidationInstructions", { + value: `Run 'aws acm describe-certificate --certificate-arn ${this.certificate.certificateArn} --query Certificate.DomainValidationOptions' to get DNS validation records`, + description: "Instructions for DNS validation", + }); + } +} diff --git a/infrastructure/cdk/lib/ecr-stack.ts b/infrastructure/cdk/lib/ecr-stack.ts new file mode 100644 index 0000000..a2180c1 --- /dev/null +++ b/infrastructure/cdk/lib/ecr-stack.ts @@ -0,0 +1,90 @@ +import * as cdk from "aws-cdk-lib"; +import * as ecr from "aws-cdk-lib/aws-ecr"; +import { Construct } from "constructs"; + +interface EcrStackProps extends cdk.StackProps { + /** + * Environment name (dev, staging, prod) + */ + environment: string; + + /** + * Number of images to retain (default: 10) + */ + maxImageCount?: number; +} + +export class EcrStack extends cdk.Stack { + /** + * Backend Docker image repository + */ + public readonly backendRepository: ecr.Repository; + + /** + * Frontend Docker image repository + */ + public readonly frontendRepository: ecr.Repository; + + constructor(scope: Construct, id: string, props: EcrStackProps) { + super(scope, id, props); + + const { environment, maxImageCount = 10 } = props; + + // Backend repository + this.backendRepository = new ecr.Repository(this, "BackendRepository", { + repositoryName: `rentall-backend-${environment}`, + removalPolicy: cdk.RemovalPolicy.RETAIN, + imageScanOnPush: true, + imageTagMutability: ecr.TagMutability.MUTABLE, + lifecycleRules: [ + { + rulePriority: 1, + description: `Keep only the last ${maxImageCount} images`, + maxImageCount: maxImageCount, + tagStatus: ecr.TagStatus.ANY, + }, + ], + }); + + // Frontend repository + this.frontendRepository = new ecr.Repository(this, "FrontendRepository", { + repositoryName: `rentall-frontend-${environment}`, + removalPolicy: cdk.RemovalPolicy.RETAIN, + imageScanOnPush: true, + imageTagMutability: ecr.TagMutability.MUTABLE, + lifecycleRules: [ + { + rulePriority: 1, + description: `Keep only the last ${maxImageCount} images`, + maxImageCount: maxImageCount, + tagStatus: ecr.TagStatus.ANY, + }, + ], + }); + + // Outputs + new cdk.CfnOutput(this, "BackendRepositoryUri", { + value: this.backendRepository.repositoryUri, + description: "Backend ECR repository URI", + exportName: `BackendRepositoryUri-${environment}`, + }); + + new cdk.CfnOutput(this, "BackendRepositoryName", { + value: this.backendRepository.repositoryName, + description: "Backend ECR repository name", + exportName: `BackendRepositoryName-${environment}`, + }); + + new cdk.CfnOutput(this, "FrontendRepositoryUri", { + value: this.frontendRepository.repositoryUri, + description: "Frontend ECR repository URI", + exportName: `FrontendRepositoryUri-${environment}`, + }); + + new cdk.CfnOutput(this, "FrontendRepositoryName", { + value: this.frontendRepository.repositoryName, + description: "Frontend ECR repository name", + exportName: `FrontendRepositoryName-${environment}`, + }); + } +} diff --git a/infrastructure/cdk/lib/ecs-service-stack.ts b/infrastructure/cdk/lib/ecs-service-stack.ts new file mode 100644 index 0000000..b0d0c27 --- /dev/null +++ b/infrastructure/cdk/lib/ecs-service-stack.ts @@ -0,0 +1,483 @@ +import * as cdk from "aws-cdk-lib"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import * as ecs from "aws-cdk-lib/aws-ecs"; +import * as ecr from "aws-cdk-lib/aws-ecr"; +import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2"; +import * as logs from "aws-cdk-lib/aws-logs"; +import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; +import * as acm from "aws-cdk-lib/aws-certificatemanager"; +import * as iam from "aws-cdk-lib/aws-iam"; +import { Construct } from "constructs"; + +interface EcsServiceStackProps extends cdk.StackProps { + /** + * Environment name (dev, staging, prod) + */ + environment: string; + + /** + * VPC to deploy services in + */ + vpc: ec2.IVpc; + + /** + * ACM certificate for HTTPS + */ + certificate: acm.ICertificate; + + /** + * Backend ECR repository + */ + backendRepository: ecr.IRepository; + + /** + * Frontend ECR repository + */ + frontendRepository: ecr.IRepository; + + /** + * Database credentials secret + */ + databaseSecret: secretsmanager.ISecret; + + /** + * Application secrets (JWT, etc.) + */ + appSecret: secretsmanager.ISecret; + + /** + * Database security group (to allow ECS -> RDS access) + */ + databaseSecurityGroup: ec2.ISecurityGroup; + + /** + * Database endpoint + */ + dbEndpoint: string; + + /** + * Database port + */ + dbPort: number; + + /** + * Database name + */ + dbName: string; + + /** + * Domain name for the environment (e.g., dev.village-share.com) + */ + domainName: string; + + /** + * IP address to restrict ALB access to (CIDR format, e.g., "1.2.3.4/32") + * If not provided, ALB is open to the internet + */ + allowedIp?: string; + + /** + * Frontend URL for CORS configuration + */ + frontendUrl: string; +} + +export class EcsServiceStack extends cdk.Stack { + /** + * The ECS cluster + */ + public readonly cluster: ecs.Cluster; + + /** + * The Application Load Balancer + */ + public readonly alb: elbv2.ApplicationLoadBalancer; + + /** + * Backend ECS service + */ + public readonly backendService: ecs.FargateService; + + /** + * Frontend ECS service + */ + public readonly frontendService: ecs.FargateService; + + constructor(scope: Construct, id: string, props: EcsServiceStackProps) { + super(scope, id, props); + + const { + environment, + vpc, + certificate, + backendRepository, + frontendRepository, + databaseSecret, + appSecret, + databaseSecurityGroup, + dbEndpoint, + dbPort, + dbName, + domainName, + allowedIp, + frontendUrl, + } = props; + + // ECS Cluster with Container Insights + this.cluster = new ecs.Cluster(this, "Cluster", { + clusterName: `rentall-cluster-${environment}`, + vpc, + containerInsights: true, + }); + + // ALB Security Group + const albSecurityGroup = new ec2.SecurityGroup(this, "AlbSecurityGroup", { + vpc, + securityGroupName: `rentall-alb-sg-${environment}`, + description: `ALB security group for rentall ${environment}`, + allowAllOutbound: true, + }); + + // Configure ALB access based on allowedIp + if (allowedIp) { + // Restrict to specific IP (dev environment) + albSecurityGroup.addIngressRule( + ec2.Peer.ipv4(allowedIp), + ec2.Port.tcp(443), + `Allow HTTPS from ${allowedIp}` + ); + albSecurityGroup.addIngressRule( + ec2.Peer.ipv4(allowedIp), + ec2.Port.tcp(80), + `Allow HTTP from ${allowedIp} (for redirect)` + ); + } else { + // Open to the internet (staging/prod) + albSecurityGroup.addIngressRule( + ec2.Peer.anyIpv4(), + ec2.Port.tcp(443), + "Allow HTTPS from anywhere" + ); + albSecurityGroup.addIngressRule( + ec2.Peer.anyIpv4(), + ec2.Port.tcp(80), + "Allow HTTP from anywhere (for redirect)" + ); + } + + // Application Load Balancer + this.alb = new elbv2.ApplicationLoadBalancer(this, "Alb", { + loadBalancerName: `rentall-alb-${environment}`, + vpc, + internetFacing: true, + securityGroup: albSecurityGroup, + vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC }, + }); + + // HTTPS Listener (port 443) + const httpsListener = this.alb.addListener("HttpsListener", { + port: 443, + protocol: elbv2.ApplicationProtocol.HTTPS, + certificates: [certificate], + sslPolicy: elbv2.SslPolicy.TLS12, + }); + + // HTTP Listener (port 80) - Redirect to HTTPS + this.alb.addListener("HttpListener", { + port: 80, + protocol: elbv2.ApplicationProtocol.HTTP, + defaultAction: elbv2.ListenerAction.redirect({ + protocol: "HTTPS", + port: "443", + permanent: true, + }), + }); + + // Backend Security Group + const backendSecurityGroup = new ec2.SecurityGroup( + this, + "BackendSecurityGroup", + { + vpc, + securityGroupName: `rentall-backend-sg-${environment}`, + description: `Backend service security group (${environment})`, + allowAllOutbound: true, + } + ); + + // Allow ALB to reach backend + backendSecurityGroup.addIngressRule( + albSecurityGroup, + ec2.Port.tcp(5000), + "Allow traffic from ALB" + ); + + // Allow backend to reach database + databaseSecurityGroup.addIngressRule( + backendSecurityGroup, + ec2.Port.tcp(dbPort), + "Allow traffic from backend ECS" + ); + + // Frontend Security Group + const frontendSecurityGroup = new ec2.SecurityGroup( + this, + "FrontendSecurityGroup", + { + vpc, + securityGroupName: `rentall-frontend-sg-${environment}`, + description: `Frontend service security group (${environment})`, + allowAllOutbound: true, + } + ); + + // Allow ALB to reach frontend + frontendSecurityGroup.addIngressRule( + albSecurityGroup, + ec2.Port.tcp(80), + "Allow traffic from ALB" + ); + + // CloudWatch Log Groups + const backendLogGroup = new logs.LogGroup(this, "BackendLogGroup", { + logGroupName: `/ecs/rentall-backend-${environment}`, + retention: logs.RetentionDays.ONE_MONTH, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + const frontendLogGroup = new logs.LogGroup(this, "FrontendLogGroup", { + logGroupName: `/ecs/rentall-frontend-${environment}`, + retention: logs.RetentionDays.ONE_MONTH, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + // Backend Task Definition + const backendTaskDef = new ecs.FargateTaskDefinition( + this, + "BackendTaskDef", + { + family: `rentall-backend-${environment}`, + cpu: 512, // 0.5 vCPU + memoryLimitMiB: 1024, // 1 GB + } + ); + + // Grant secrets access to backend task + databaseSecret.grantRead(backendTaskDef.taskRole); + appSecret.grantRead(backendTaskDef.taskRole); + + // Backend container + const backendContainer = backendTaskDef.addContainer("backend", { + containerName: "backend", + image: ecs.ContainerImage.fromEcrRepository(backendRepository, "latest"), + logging: ecs.LogDriver.awsLogs({ + logGroup: backendLogGroup, + streamPrefix: "backend", + }), + environment: { + NODE_ENV: environment === "prod" ? "production" : "development", + PORT: "5000", + DB_HOST: dbEndpoint, + DB_PORT: dbPort.toString(), + DB_NAME: dbName, + FRONTEND_URL: frontendUrl, + CORS_ORIGIN: frontendUrl, + }, + secrets: { + DB_USER: ecs.Secret.fromSecretsManager(databaseSecret, "username"), + DB_PASSWORD: ecs.Secret.fromSecretsManager(databaseSecret, "password"), + JWT_SECRET: ecs.Secret.fromSecretsManager(appSecret, "jwtSecret"), + }, + portMappings: [ + { + containerPort: 5000, + protocol: ecs.Protocol.TCP, + }, + ], + healthCheck: { + command: [ + "CMD-SHELL", + "curl -f http://localhost:5000/api/health || exit 1", + ], + interval: cdk.Duration.seconds(30), + timeout: cdk.Duration.seconds(5), + retries: 3, + startPeriod: cdk.Duration.seconds(60), + }, + }); + + // Backend Service + this.backendService = new ecs.FargateService(this, "BackendService", { + serviceName: `backend-${environment}`, + cluster: this.cluster, + taskDefinition: backendTaskDef, + desiredCount: 1, + securityGroups: [backendSecurityGroup], + vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + enableExecuteCommand: true, // Enable ECS Exec for debugging/migrations + circuitBreaker: { rollback: true }, + minHealthyPercent: 100, + maxHealthyPercent: 200, + }); + + // Frontend Task Definition (Fargate Spot for cost savings) + const frontendTaskDef = new ecs.FargateTaskDefinition( + this, + "FrontendTaskDef", + { + family: `rentall-frontend-${environment}`, + cpu: 256, // 0.25 vCPU + memoryLimitMiB: 512, // 512 MB + } + ); + + // Frontend container + const frontendContainer = frontendTaskDef.addContainer("frontend", { + containerName: "frontend", + image: ecs.ContainerImage.fromEcrRepository(frontendRepository, "latest"), + logging: ecs.LogDriver.awsLogs({ + logGroup: frontendLogGroup, + streamPrefix: "frontend", + }), + portMappings: [ + { + containerPort: 80, + protocol: ecs.Protocol.TCP, + }, + ], + healthCheck: { + command: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"], + interval: cdk.Duration.seconds(30), + timeout: cdk.Duration.seconds(5), + retries: 3, + startPeriod: cdk.Duration.seconds(30), + }, + }); + + // Frontend Service (using Fargate Spot for 70% cost savings) + this.frontendService = new ecs.FargateService(this, "FrontendService", { + serviceName: `frontend-${environment}`, + cluster: this.cluster, + taskDefinition: frontendTaskDef, + desiredCount: 1, + securityGroups: [frontendSecurityGroup], + vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + capacityProviderStrategies: [ + { + capacityProvider: "FARGATE_SPOT", + weight: 1, + }, + ], + circuitBreaker: { rollback: true }, + minHealthyPercent: 100, + maxHealthyPercent: 200, + }); + + // Backend Target Group + const backendTargetGroup = new elbv2.ApplicationTargetGroup( + this, + "BackendTargetGroup", + { + targetGroupName: `backend-tg-${environment}`, + vpc, + port: 5000, + protocol: elbv2.ApplicationProtocol.HTTP, + targetType: elbv2.TargetType.IP, + healthCheck: { + path: "/api/health", + healthyHttpCodes: "200", + interval: cdk.Duration.seconds(30), + timeout: cdk.Duration.seconds(5), + healthyThresholdCount: 2, + unhealthyThresholdCount: 3, + }, + deregistrationDelay: cdk.Duration.seconds(30), + } + ); + + // Register backend service with target group + this.backendService.attachToApplicationTargetGroup(backendTargetGroup); + + // Frontend Target Group + const frontendTargetGroup = new elbv2.ApplicationTargetGroup( + this, + "FrontendTargetGroup", + { + targetGroupName: `frontend-tg-${environment}`, + vpc, + port: 80, + protocol: elbv2.ApplicationProtocol.HTTP, + targetType: elbv2.TargetType.IP, + healthCheck: { + path: "/", + healthyHttpCodes: "200", + interval: cdk.Duration.seconds(30), + timeout: cdk.Duration.seconds(5), + healthyThresholdCount: 2, + unhealthyThresholdCount: 3, + }, + deregistrationDelay: cdk.Duration.seconds(30), + } + ); + + // Register frontend service with target group + this.frontendService.attachToApplicationTargetGroup(frontendTargetGroup); + + // Configure listener rules for path-based routing + // /api/* -> backend + httpsListener.addTargetGroups("BackendRule", { + targetGroups: [backendTargetGroup], + priority: 10, + conditions: [elbv2.ListenerCondition.pathPatterns(["/api/*"])], + }); + + // /* -> frontend (default) + httpsListener.addTargetGroups("FrontendRule", { + targetGroups: [frontendTargetGroup], + priority: 20, + conditions: [elbv2.ListenerCondition.pathPatterns(["/*"])], + }); + + // Outputs + new cdk.CfnOutput(this, "ClusterName", { + value: this.cluster.clusterName, + description: "ECS Cluster name", + exportName: `ClusterName-${environment}`, + }); + + new cdk.CfnOutput(this, "AlbDnsName", { + value: this.alb.loadBalancerDnsName, + description: "ALB DNS name - add CNAME record pointing to this", + exportName: `AlbDnsName-${environment}`, + }); + + new cdk.CfnOutput(this, "AlbArn", { + value: this.alb.loadBalancerArn, + description: "ALB ARN", + exportName: `AlbArn-${environment}`, + }); + + new cdk.CfnOutput(this, "ServiceUrl", { + value: `https://${domainName}`, + description: "Service URL", + }); + + new cdk.CfnOutput(this, "BackendServiceName", { + value: this.backendService.serviceName, + description: "Backend service name", + exportName: `BackendServiceName-${environment}`, + }); + + new cdk.CfnOutput(this, "FrontendServiceName", { + value: this.frontendService.serviceName, + description: "Frontend service name", + exportName: `FrontendServiceName-${environment}`, + }); + + // Instructions for accessing the service + new cdk.CfnOutput(this, "DnsInstructions", { + value: `Add CNAME record: ${domainName} -> ${this.alb.loadBalancerDnsName}`, + description: "DNS configuration instructions", + }); + } +} diff --git a/infrastructure/cdk/lib/rds-stack.ts b/infrastructure/cdk/lib/rds-stack.ts new file mode 100644 index 0000000..c748ce1 --- /dev/null +++ b/infrastructure/cdk/lib/rds-stack.ts @@ -0,0 +1,174 @@ +import * as cdk from "aws-cdk-lib"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import * as rds from "aws-cdk-lib/aws-rds"; +import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; +import { Construct } from "constructs"; + +interface RdsStackProps extends cdk.StackProps { + /** + * Environment name (dev, staging, prod) + */ + environment: string; + + /** + * VPC to deploy the database in + */ + vpc: ec2.IVpc; + + /** + * Database credentials secret from SecretsStack + */ + databaseSecret: secretsmanager.ISecret; + + /** + * Database name (default: rentall) + */ + databaseName?: string; + + /** + * Instance type (default: t3.micro for Free Tier) + */ + instanceType?: ec2.InstanceType; + + /** + * Allocated storage in GB (default: 20) + */ + allocatedStorage?: number; + + /** + * Enable Multi-AZ deployment (default: false for dev/staging) + */ + multiAz?: boolean; + + /** + * Backup retention days (default: 7) + */ + backupRetentionDays?: number; +} + +export class RdsStack extends cdk.Stack { + /** + * The RDS database instance + */ + public readonly database: rds.DatabaseInstance; + + /** + * Security group for the database + */ + public readonly databaseSecurityGroup: ec2.SecurityGroup; + + /** + * Database endpoint address + */ + public readonly dbEndpoint: string; + + /** + * Database port + */ + public readonly dbPort: number; + + constructor(scope: Construct, id: string, props: RdsStackProps) { + super(scope, id, props); + + const { + environment, + vpc, + databaseSecret, + databaseName = "rentall", + instanceType = ec2.InstanceType.of( + ec2.InstanceClass.T3, + ec2.InstanceSize.MICRO + ), + allocatedStorage = 20, + multiAz = false, + backupRetentionDays = 7, + } = props; + + // Security group for the database + this.databaseSecurityGroup = new ec2.SecurityGroup( + this, + "DatabaseSecurityGroup", + { + vpc, + securityGroupName: `rentall-db-sg-${environment}`, + description: `Security group for RDS database (${environment})`, + allowAllOutbound: false, + } + ); + + // Create the RDS instance + this.database = new rds.DatabaseInstance(this, "Database", { + instanceIdentifier: `rentall-db-${environment}`, + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_15, + }), + instanceType, + vpc, + vpcSubnets: { + subnetType: ec2.SubnetType.PRIVATE_ISOLATED, + }, + securityGroups: [this.databaseSecurityGroup], + credentials: rds.Credentials.fromSecret(databaseSecret), + databaseName, + allocatedStorage, + maxAllocatedStorage: allocatedStorage * 2, // Allow storage autoscaling up to 2x + storageType: rds.StorageType.GP2, + multiAz, + autoMinorVersionUpgrade: true, + deletionProtection: environment === "prod", + removalPolicy: + environment === "prod" + ? cdk.RemovalPolicy.RETAIN + : cdk.RemovalPolicy.DESTROY, + backupRetention: cdk.Duration.days(backupRetentionDays), + preferredBackupWindow: "03:00-04:00", // UTC + preferredMaintenanceWindow: "Sun:04:00-Sun:05:00", // UTC + storageEncrypted: true, + monitoringInterval: cdk.Duration.seconds(60), + enablePerformanceInsights: true, + performanceInsightRetention: rds.PerformanceInsightRetention.DEFAULT, // 7 days (free) + parameterGroup: new rds.ParameterGroup(this, "ParameterGroup", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_15, + }), + parameters: { + // Enforce SSL connections + "rds.force_ssl": "1", + // Log slow queries (> 1 second) + log_min_duration_statement: "1000", + }, + }), + publiclyAccessible: false, + }); + + this.dbEndpoint = this.database.dbInstanceEndpointAddress; + this.dbPort = this.database.dbInstanceEndpointPort + ? parseInt(this.database.dbInstanceEndpointPort) + : 5432; + + // Outputs + new cdk.CfnOutput(this, "DatabaseEndpoint", { + value: this.database.dbInstanceEndpointAddress, + description: "Database endpoint address", + exportName: `DatabaseEndpoint-${environment}`, + }); + + new cdk.CfnOutput(this, "DatabasePort", { + value: this.database.dbInstanceEndpointPort, + description: "Database port", + exportName: `DatabasePort-${environment}`, + }); + + new cdk.CfnOutput(this, "DatabaseSecurityGroupId", { + value: this.databaseSecurityGroup.securityGroupId, + description: "Database security group ID", + exportName: `DatabaseSecurityGroupId-${environment}`, + }); + + new cdk.CfnOutput(this, "DatabaseInstanceIdentifier", { + value: this.database.instanceIdentifier, + description: "Database instance identifier", + exportName: `DatabaseInstanceIdentifier-${environment}`, + }); + } +} diff --git a/infrastructure/cdk/lib/secrets-stack.ts b/infrastructure/cdk/lib/secrets-stack.ts new file mode 100644 index 0000000..315c363 --- /dev/null +++ b/infrastructure/cdk/lib/secrets-stack.ts @@ -0,0 +1,87 @@ +import * as cdk from "aws-cdk-lib"; +import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; +import { Construct } from "constructs"; + +interface SecretsStackProps extends cdk.StackProps { + /** + * Environment name (dev, staging, prod) + */ + environment: string; + + /** + * Database username (default: rentall_admin) + */ + dbUsername?: string; +} + +export class SecretsStack extends cdk.Stack { + /** + * Database credentials secret + */ + public readonly databaseSecret: secretsmanager.Secret; + + /** + * Application secrets (JWT, etc.) + */ + public readonly appSecret: secretsmanager.Secret; + + constructor(scope: Construct, id: string, props: SecretsStackProps) { + super(scope, id, props); + + const { environment, dbUsername = "rentall_admin" } = props; + + // Database credentials secret with auto-generated password + this.databaseSecret = new secretsmanager.Secret(this, "DatabaseSecret", { + secretName: `rentall/${environment}/database`, + description: `Database credentials for rentall ${environment} environment`, + generateSecretString: { + secretStringTemplate: JSON.stringify({ + username: dbUsername, + }), + generateStringKey: "password", + excludePunctuation: true, + excludeCharacters: '/@"\'\\', + passwordLength: 32, + }, + }); + + // Application secrets (JWT secret, etc.) + this.appSecret = new secretsmanager.Secret(this, "AppSecret", { + secretName: `rentall/${environment}/app`, + description: `Application secrets for rentall ${environment} environment`, + generateSecretString: { + secretStringTemplate: JSON.stringify({ + // Add any additional app secrets here + }), + generateStringKey: "jwtSecret", + excludePunctuation: false, + passwordLength: 64, + }, + }); + + // Outputs + new cdk.CfnOutput(this, "DatabaseSecretArn", { + value: this.databaseSecret.secretArn, + description: "Database credentials secret ARN", + exportName: `DatabaseSecretArn-${environment}`, + }); + + new cdk.CfnOutput(this, "DatabaseSecretName", { + value: this.databaseSecret.secretName, + description: "Database credentials secret name", + exportName: `DatabaseSecretName-${environment}`, + }); + + new cdk.CfnOutput(this, "AppSecretArn", { + value: this.appSecret.secretArn, + description: "Application secrets ARN", + exportName: `AppSecretArn-${environment}`, + }); + + new cdk.CfnOutput(this, "AppSecretName", { + value: this.appSecret.secretName, + description: "Application secrets name", + exportName: `AppSecretName-${environment}`, + }); + } +}