From 7ca76b62eca60d69bd1f34a66032d9afa30b60ee Mon Sep 17 00:00:00 2001 From: fahd Date: Wed, 12 Aug 2026 21:23:11 +0300 Subject: [PATCH] FEAT: Deploy ECS For CMS and Discovery --- infrastructure/main.go | 314 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 312 insertions(+), 2 deletions(-) diff --git a/infrastructure/main.go b/infrastructure/main.go index 5a70a5d..cc69b7b 100644 --- a/infrastructure/main.go +++ b/infrastructure/main.go @@ -8,14 +8,23 @@ import ( "strings" "github.com/pulumi/pulumi-aws/sdk/v7/go/aws/cloudfront" + "github.com/pulumi/pulumi-aws/sdk/v7/go/aws/cloudwatch" "github.com/pulumi/pulumi-aws/sdk/v7/go/aws/ec2" + "github.com/pulumi/pulumi-aws/sdk/v7/go/aws/ecr" + "github.com/pulumi/pulumi-aws/sdk/v7/go/aws/ecs" + "github.com/pulumi/pulumi-aws/sdk/v7/go/aws/iam" + "github.com/pulumi/pulumi-aws/sdk/v7/go/aws/lb" "github.com/pulumi/pulumi-aws/sdk/v7/go/aws/rds" "github.com/pulumi/pulumi-aws/sdk/v7/go/aws/s3" + "github.com/pulumi/pulumi-aws/sdk/v7/go/aws/secretsmanager" "github.com/pulumi/pulumi-postgresql/sdk/v3/go/postgresql" "github.com/pulumi/pulumi-random/sdk/v4/go/random" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" ) +// Matches the region configured in Pulumi.main.yaml (aws:region). +const awsRegion = "us-east-1" + // currentPublicIP returns the caller's public IP, used to scope the RDS // security group to the machine that will actually run `pulumi up`. func currentPublicIP() (string, error) { @@ -71,6 +80,197 @@ func newServiceDatabase(ctx *pulumi.Context, provider *postgresql.Provider, serv return password, nil } +// deployFargateService gives a service its own ECR repo, log group, task +// definition, ECS service, and its own ALB — so it gets its own DNS name +// rather than sharing one load balancer with the other service on a +// different port. The service's DB password is stored in Secrets Manager and +// injected into the container as a secret rather than a plaintext env var. +func deployFargateService( + ctx *pulumi.Context, + name string, + containerPort int, + cluster *ecs.Cluster, + execRole *iam.Role, + vpcID string, + subnetIDs []string, + albSecurityGroup *ec2.SecurityGroup, + serviceSecurityGroup *ec2.SecurityGroup, + dbAddress pulumi.StringOutput, + dbPort pulumi.IntOutput, + dbPassword *random.RandomPassword, +) (*ecr.Repository, *lb.LoadBalancer, error) { + alb, err := lb.NewLoadBalancer(ctx, name+"-alb", &lb.LoadBalancerArgs{ + LoadBalancerType: pulumi.String("application"), + Internal: pulumi.Bool(false), + SecurityGroups: pulumi.StringArray{albSecurityGroup.ID()}, + Subnets: pulumi.ToStringArray(subnetIDs), + }) + if err != nil { + return nil, nil, err + } + + repo, err := ecr.NewRepository(ctx, name+"-repo", &ecr.RepositoryArgs{ + Name: pulumi.String(name), + ImageTagMutability: pulumi.String("MUTABLE"), + ForceDelete: pulumi.Bool(true), + }) + if err != nil { + return nil, nil, err + } + + logGroup, err := cloudwatch.NewLogGroup(ctx, name+"-logs", &cloudwatch.LogGroupArgs{ + Name: pulumi.String("/ecs/" + name), + RetentionInDays: pulumi.Int(7), + }) + if err != nil { + return nil, nil, err + } + + secret, err := secretsmanager.NewSecret(ctx, name+"-db-secret", &secretsmanager.SecretArgs{ + Name: pulumi.String(name + "-db-password"), + RecoveryWindowInDays: pulumi.Int(0), + }) + if err != nil { + return nil, nil, err + } + + _, err = secretsmanager.NewSecretVersion(ctx, name+"-db-secret-version", &secretsmanager.SecretVersionArgs{ + SecretId: secret.ID(), + SecretString: dbPassword.Result, + }) + if err != nil { + return nil, nil, err + } + + secretPolicy := secret.Arn.ApplyT(func(secretArn string) (string, error) { + doc := map[string]any{ + "Version": "2012-10-17", + "Statement": []map[string]any{ + { + "Effect": "Allow", + "Action": "secretsmanager:GetSecretValue", + "Resource": secretArn, + }, + }, + } + b, err := json.Marshal(doc) + return string(b), err + }).(pulumi.StringOutput) + + _, err = iam.NewRolePolicy(ctx, name+"-secret-access", &iam.RolePolicyArgs{ + Role: execRole.ID(), + Policy: secretPolicy, + }) + if err != nil { + return nil, nil, err + } + + targetGroup, err := lb.NewTargetGroup(ctx, name+"-tg", &lb.TargetGroupArgs{ + Port: pulumi.Int(containerPort), + Protocol: pulumi.String("HTTP"), + TargetType: pulumi.String("ip"), + VpcId: pulumi.String(vpcID), + HealthCheck: &lb.TargetGroupHealthCheckArgs{ + Path: pulumi.String("/health"), + Matcher: pulumi.String("200"), + }, + }) + if err != nil { + return nil, nil, err + } + + listener, err := lb.NewListener(ctx, name+"-listener", &lb.ListenerArgs{ + LoadBalancerArn: alb.Arn, + Port: pulumi.Int(80), + Protocol: pulumi.String("HTTP"), + DefaultActions: lb.ListenerDefaultActionArray{ + &lb.ListenerDefaultActionArgs{ + Type: pulumi.String("forward"), + TargetGroupArn: targetGroup.Arn, + }, + }, + }) + if err != nil { + return nil, nil, err + } + + containerDefinitions := pulumi.All(repo.RepositoryUrl, logGroup.Name, secret.Arn, dbAddress, dbPort).ApplyT( + func(args []any) (string, error) { + image := args[0].(string) + ":latest" + logGroupName := args[1].(string) + secretArn := args[2].(string) + dbHost := args[3].(string) + dbPort := args[4].(int) + + def := []map[string]any{ + { + "name": name, + "image": image, + "portMappings": []map[string]any{ + {"containerPort": containerPort, "protocol": "tcp"}, + }, + "environment": []map[string]string{ + {"name": "DB_HOST", "value": dbHost}, + {"name": "DB_PORT", "value": fmt.Sprintf("%d", dbPort)}, + {"name": "DB_NAME", "value": name}, + {"name": "DB_USER", "value": name}, + }, + "secrets": []map[string]string{ + {"name": "DB_PASSWORD", "valueFrom": secretArn}, + }, + "logConfiguration": map[string]any{ + "logDriver": "awslogs", + "options": map[string]string{ + "awslogs-group": logGroupName, + "awslogs-region": awsRegion, + "awslogs-stream-prefix": name, + }, + }, + }, + } + b, err := json.Marshal(def) + return string(b), err + }, + ).(pulumi.StringOutput) + + taskDefinition, err := ecs.NewTaskDefinition(ctx, name+"-task", &ecs.TaskDefinitionArgs{ + Family: pulumi.String(name), + Cpu: pulumi.String("256"), + Memory: pulumi.String("512"), + NetworkMode: pulumi.String("awsvpc"), + RequiresCompatibilities: pulumi.StringArray{pulumi.String("FARGATE")}, + ExecutionRoleArn: execRole.Arn, + ContainerDefinitions: containerDefinitions, + }) + if err != nil { + return nil, nil, err + } + + _, err = ecs.NewService(ctx, name+"-service", &ecs.ServiceArgs{ + Cluster: cluster.Arn, + TaskDefinition: taskDefinition.Arn, + DesiredCount: pulumi.Int(1), + LaunchType: pulumi.String("FARGATE"), + NetworkConfiguration: &ecs.ServiceNetworkConfigurationArgs{ + Subnets: pulumi.ToStringArray(subnetIDs), + SecurityGroups: pulumi.StringArray{serviceSecurityGroup.ID()}, + AssignPublicIp: pulumi.Bool(true), + }, + LoadBalancers: ecs.ServiceLoadBalancerArray{ + &ecs.ServiceLoadBalancerArgs{ + TargetGroupArn: targetGroup.Arn, + ContainerName: pulumi.String(name), + ContainerPort: pulumi.Int(containerPort), + }, + }, + }, pulumi.DependsOn([]pulumi.Resource{listener})) + if err != nil { + return nil, nil, err + } + + return repo, alb, nil +} + func main() { pulumi.Run(func(ctx *pulumi.Context) error { // Create an AWS resource (S3 Bucket) that holds encoded assets @@ -277,12 +477,118 @@ func main() { return err } - _, err = newServiceDatabase(ctx, pgProvider, "cms") + cmsPassword, err := newServiceDatabase(ctx, pgProvider, "cms") if err != nil { return err } - _, err = newServiceDatabase(ctx, pgProvider, "discovery") + discoveryPassword, err := newServiceDatabase(ctx, pgProvider, "discovery") + if err != nil { + return err + } + + // ECS Fargate: one cluster, one internet-facing ALB, one service per app. + cluster, err := ecs.NewCluster(ctx, "app-cluster", nil) + if err != nil { + return err + } + + _, err = ecs.NewClusterCapacityProviders(ctx, "app-cluster-capacity-providers", &ecs.ClusterCapacityProvidersArgs{ + ClusterName: cluster.Name, + CapacityProviders: pulumi.ToStringArray([]string{"FARGATE", "FARGATE_SPOT"}), + DefaultCapacityProviderStrategies: ecs.ClusterCapacityProvidersDefaultCapacityProviderStrategyArray{ + &ecs.ClusterCapacityProvidersDefaultCapacityProviderStrategyArgs{ + CapacityProvider: pulumi.String("FARGATE"), + Weight: pulumi.Int(1), + }, + }, + }) + if err != nil { + return err + } + + albSecurityGroup, err := ec2.NewSecurityGroup(ctx, "alb-sg", &ec2.SecurityGroupArgs{ + Description: pulumi.String("Allow inbound HTTP to the app load balancers"), + VpcId: pulumi.String(vpc.Id), + Ingress: ec2.SecurityGroupIngressArray{ + &ec2.SecurityGroupIngressArgs{ + Protocol: pulumi.String("tcp"), + FromPort: pulumi.Int(80), + ToPort: pulumi.Int(80), + CidrBlocks: pulumi.ToStringArray([]string{"0.0.0.0/0"}), + }, + }, + Egress: ec2.SecurityGroupEgressArray{ + &ec2.SecurityGroupEgressArgs{ + Protocol: pulumi.String("-1"), + FromPort: pulumi.Int(0), + ToPort: pulumi.Int(0), + CidrBlocks: pulumi.ToStringArray([]string{"0.0.0.0/0"}), + }, + }, + }) + if err != nil { + return err + } + + serviceSecurityGroup, err := ec2.NewSecurityGroup(ctx, "ecs-service-sg", &ec2.SecurityGroupArgs{ + Description: pulumi.String("Allow the ALB to reach cms and discovery tasks"), + VpcId: pulumi.String(vpc.Id), + Ingress: ec2.SecurityGroupIngressArray{ + &ec2.SecurityGroupIngressArgs{ + Protocol: pulumi.String("tcp"), + FromPort: pulumi.Int(0), + ToPort: pulumi.Int(65535), + SecurityGroups: pulumi.StringArray{albSecurityGroup.ID()}, + }, + }, + Egress: ec2.SecurityGroupEgressArray{ + &ec2.SecurityGroupEgressArgs{ + Protocol: pulumi.String("-1"), + FromPort: pulumi.Int(0), + ToPort: pulumi.Int(0), + CidrBlocks: pulumi.ToStringArray([]string{"0.0.0.0/0"}), + }, + }, + }) + if err != nil { + return err + } + + execRoleAssumePolicy, err := json.Marshal(map[string]any{ + "Version": "2012-10-17", + "Statement": []map[string]any{ + { + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Principal": map[string]string{"Service": "ecs-tasks.amazonaws.com"}, + }, + }, + }) + if err != nil { + return err + } + + execRole, err := iam.NewRole(ctx, "ecs-task-execution-role", &iam.RoleArgs{ + AssumeRolePolicy: pulumi.String(execRoleAssumePolicy), + ManagedPolicyArns: pulumi.ToStringArray([]string{ + "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", + }), + }) + if err != nil { + return err + } + + cmsRepo, cmsAlb, err := deployFargateService(ctx, "cms", 8081, + cluster, execRole, vpc.Id, subnets.Ids, albSecurityGroup, serviceSecurityGroup, + db.Address, db.Port, cmsPassword) + if err != nil { + return err + } + + discoveryRepo, discoveryAlb, err := deployFargateService(ctx, "discovery", 8080, + cluster, execRole, vpc.Id, subnets.Ids, albSecurityGroup, serviceSecurityGroup, + db.Address, db.Port, discoveryPassword) if err != nil { return err } @@ -292,6 +598,10 @@ func main() { ctx.Export("cdnDomainName", distribution.DomainName) ctx.Export("dbEndpoint", db.Endpoint) ctx.Export("dbPassword", dbPassword.Result) + ctx.Export("cmsUrl", pulumi.Sprintf("http://%s", cmsAlb.DnsName)) + ctx.Export("discoveryUrl", pulumi.Sprintf("http://%s", discoveryAlb.DnsName)) + ctx.Export("cmsRepoUrl", cmsRepo.RepositoryUrl) + ctx.Export("discoveryRepoUrl", discoveryRepo.RepositoryUrl) return nil }) }