484 lines
13 KiB
TypeScript
484 lines
13 KiB
TypeScript
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",
|
|
});
|
|
}
|
|
}
|