├── LICENSE ├── requirements.txt └── stack ├── __init__.py ├── assets.py ├── certificates.py ├── cluster.py ├── database.py ├── domain.py ├── repository.py ├── services ├── __init__.py └── application.py ├── template.py └── vpc.py /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Jean Phix, Tobias McNulty 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | awacs==0.5.4 2 | troposphere==1.8.1 3 | -------------------------------------------------------------------------------- /stack/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/ecs/4aeaebb0df8980c1f7327203df757314fb961506/stack/__init__.py -------------------------------------------------------------------------------- /stack/assets.py: -------------------------------------------------------------------------------- 1 | from troposphere import ( 2 | Join, 3 | Output, 4 | GetAtt, 5 | ) 6 | 7 | from troposphere.s3 import ( 8 | Bucket, 9 | CorsConfiguration, 10 | CorsRules, 11 | PublicRead, 12 | VersioningConfiguration, 13 | ) 14 | 15 | from troposphere.cloudfront import ( 16 | DefaultCacheBehavior, 17 | Distribution, 18 | DistributionConfig, 19 | ForwardedValues, 20 | Origin, 21 | S3Origin, 22 | ) 23 | 24 | from .template import template 25 | from .domain import domain_name 26 | 27 | 28 | # Create an S3 bucket that holds statics and media 29 | assets_bucket = template.add_resource( 30 | Bucket( 31 | "AssetsBucket", 32 | AccessControl=PublicRead, 33 | VersioningConfiguration=VersioningConfiguration( 34 | Status="Enabled" 35 | ), 36 | DeletionPolicy="Retain", 37 | CorsConfiguration=CorsConfiguration( 38 | CorsRules=[CorsRules( 39 | AllowedOrigins=[Join("", [ 40 | "https://*.", 41 | domain_name, 42 | ])], 43 | AllowedMethods=["POST", "PUT", "HEAD", "GET", ], 44 | AllowedHeaders=[ 45 | "*", 46 | ] 47 | )] 48 | ), 49 | ) 50 | ) 51 | 52 | 53 | # Output S3 asset bucket name 54 | template.add_output(Output( 55 | "AssetsBucketDomainName", 56 | Description="Assets bucket domain name", 57 | Value=GetAtt(assets_bucket, "DomainName") 58 | )) 59 | 60 | 61 | # Create a CloudFront CDN distribution 62 | distribution = template.add_resource( 63 | Distribution( 64 | 'AssetsDistribution', 65 | DistributionConfig=DistributionConfig( 66 | Origins=[Origin( 67 | Id="Assets", 68 | DomainName=GetAtt(assets_bucket, "DomainName"), 69 | S3OriginConfig=S3Origin( 70 | OriginAccessIdentity="", 71 | ), 72 | )], 73 | DefaultCacheBehavior=DefaultCacheBehavior( 74 | TargetOriginId="Assets", 75 | ForwardedValues=ForwardedValues( 76 | QueryString=False 77 | ), 78 | ViewerProtocolPolicy="allow-all", 79 | ), 80 | Enabled=True 81 | ), 82 | ) 83 | ) 84 | 85 | 86 | # Output CloudFront url 87 | template.add_output(Output( 88 | "AssetsDistributionDomainName", 89 | Description="The assest CDN domain name", 90 | Value=GetAtt(distribution, "DomainName") 91 | )) 92 | -------------------------------------------------------------------------------- /stack/certificates.py: -------------------------------------------------------------------------------- 1 | from troposphere import Ref 2 | from troposphere.certificatemanager import ( 3 | Certificate, 4 | DomainValidationOption, 5 | ) 6 | 7 | from .template import template 8 | from .domain import domain_name 9 | 10 | 11 | application = Ref(template.add_resource( 12 | Certificate( 13 | 'Certificate', 14 | DomainName=domain_name, 15 | DomainValidationOptions=[ 16 | DomainValidationOption( 17 | DomainName=domain_name, 18 | ValidationDomain=domain_name, 19 | ), 20 | ], 21 | ) 22 | )) 23 | -------------------------------------------------------------------------------- /stack/cluster.py: -------------------------------------------------------------------------------- 1 | from troposphere import ( 2 | AWS_REGION, 3 | AWS_STACK_ID, 4 | AWS_STACK_NAME, 5 | autoscaling, 6 | Base64, 7 | cloudformation, 8 | FindInMap, 9 | GetAtt, 10 | iam, 11 | Join, 12 | Output, 13 | Parameter, 14 | Ref, 15 | ) 16 | 17 | from troposphere.ec2 import ( 18 | SecurityGroup, 19 | SecurityGroupRule, 20 | ) 21 | 22 | from troposphere.ecs import ( 23 | Cluster, 24 | ) 25 | 26 | from troposphere.elasticloadbalancingv2 import ( 27 | Action, 28 | Certificate, 29 | Listener, 30 | LoadBalancer, 31 | Matcher, 32 | TargetGroup, 33 | TargetGroupAttribute, 34 | ) 35 | 36 | from awacs import ecr 37 | 38 | from .template import template 39 | from .vpc import ( 40 | vpc, 41 | loadbalancer_a_subnet, 42 | loadbalancer_a_subnet_cidr, 43 | loadbalancer_b_subnet, 44 | loadbalancer_b_subnet_cidr, 45 | container_a_subnet, 46 | container_b_subnet, 47 | ) 48 | from .assets import ( 49 | assets_bucket, 50 | ) 51 | from .certificates import application as application_certificate 52 | 53 | 54 | container_instance_type = Ref(template.add_parameter(Parameter( 55 | "ContainerInstanceType", 56 | Description="The container instance type", 57 | Type="String", 58 | Default="t2.micro", 59 | AllowedValues=["t2.micro", "t2.small", "t2.medium"] 60 | ))) 61 | 62 | 63 | web_worker_port = Ref(template.add_parameter(Parameter( 64 | "WebWorkerPort", 65 | Description="Web worker container exposed port", 66 | Type="Number", 67 | Default="8000", 68 | ))) 69 | 70 | 71 | max_container_instances = Ref(template.add_parameter(Parameter( 72 | "MaxScale", 73 | Description="Maximum container instances count", 74 | Type="Number", 75 | Default="3", 76 | ))) 77 | 78 | 79 | desired_container_instances = Ref(template.add_parameter(Parameter( 80 | "DesiredScale", 81 | Description="Desired container instances count", 82 | Type="Number", 83 | Default="3", 84 | ))) 85 | 86 | 87 | template.add_mapping("ECSRegionMap", { 88 | "us-east-1": {"AMI": "ami-eca289fb"}, 89 | "us-east-2": {"AMI": "ami-446f3521"}, 90 | "us-west-1": {"AMI": "ami-9fadf8ff"}, 91 | "us-west-2": {"AMI": "ami-7abc111a"}, 92 | "eu-west-1": {"AMI": "ami-a1491ad2"}, 93 | "eu-central-1": {"AMI": "ami-54f5303b"}, 94 | "ap-northeast-1": {"AMI": "ami-9cd57ffd"}, 95 | "ap-southeast-1": {"AMI": "ami-a900a3ca"}, 96 | "ap-southeast-2": {"AMI": "ami-5781be34"}, 97 | }) 98 | 99 | 100 | # Target group 101 | application_target_group = TargetGroup( 102 | 'ApplicationTargetGroup', 103 | template=template, 104 | VpcId=Ref(vpc), 105 | Matcher=Matcher( 106 | HttpCode='200-299', 107 | ), 108 | Port=80, 109 | Protocol='HTTP', 110 | HealthCheckIntervalSeconds=15, 111 | HealthCheckPath='/health-check', 112 | HealthCheckProtocol='HTTP', 113 | HealthCheckTimeoutSeconds=5, 114 | HealthyThresholdCount=2, 115 | UnhealthyThresholdCount=8, 116 | TargetGroupAttributes=[ 117 | TargetGroupAttribute( 118 | Key='stickiness.enabled', 119 | Value='true', 120 | ) 121 | ], 122 | ) 123 | 124 | 125 | # Web load balancer 126 | load_balancer_security_group = SecurityGroup( 127 | "LoadBalancerSecurityGroup", 128 | template=template, 129 | GroupDescription="Web load balancer security group.", 130 | VpcId=Ref(vpc), 131 | SecurityGroupIngress=[ 132 | SecurityGroupRule( 133 | IpProtocol="tcp", 134 | FromPort="443", 135 | ToPort="443", 136 | CidrIp='0.0.0.0/0', 137 | ), 138 | ], 139 | ) 140 | 141 | 142 | application_load_balancer = LoadBalancer( 143 | 'ApplicationLoadBalancer', 144 | template=template, 145 | Subnets=[ 146 | Ref(loadbalancer_a_subnet), 147 | Ref(loadbalancer_b_subnet), 148 | ], 149 | SecurityGroups=[Ref(load_balancer_security_group)], 150 | ) 151 | 152 | 153 | template.add_output(Output( 154 | "LoadBalancerDNSName", 155 | Description="Loadbalancer DNS", 156 | Value=GetAtt(application_load_balancer, "DNSName") 157 | )) 158 | 159 | 160 | application_listener = Listener( 161 | 'ApplicationListener', 162 | template=template, 163 | Certificates=[Certificate( 164 | CertificateArn=application_certificate, 165 | )], 166 | LoadBalancerArn=Ref(application_load_balancer), 167 | Protocol='HTTPS', 168 | Port=443, 169 | DefaultActions=[Action( 170 | TargetGroupArn=Ref(application_target_group), 171 | Type='forward', 172 | )] 173 | ) 174 | 175 | 176 | # ECS cluster 177 | cluster = Cluster( 178 | "Cluster", 179 | template=template, 180 | ) 181 | 182 | 183 | # ECS container role 184 | container_instance_role = iam.Role( 185 | "ContainerInstanceRole", 186 | template=template, 187 | AssumeRolePolicyDocument=dict(Statement=[dict( 188 | Effect="Allow", 189 | Principal=dict(Service=["ec2.amazonaws.com"]), 190 | Action=["sts:AssumeRole"], 191 | )]), 192 | Path="/", 193 | Policies=[ 194 | iam.Policy( 195 | PolicyName="AssetsManagementPolicy", 196 | PolicyDocument=dict( 197 | Statement=[dict( 198 | Effect="Allow", 199 | Action=[ 200 | "s3:ListBucket", 201 | ], 202 | Resource=Join("", [ 203 | "arn:aws:s3:::", 204 | Ref(assets_bucket), 205 | ]), 206 | ), dict( 207 | Effect="Allow", 208 | Action=[ 209 | "s3:*", 210 | ], 211 | Resource=Join("", [ 212 | "arn:aws:s3:::", 213 | Ref(assets_bucket), 214 | "/*", 215 | ]), 216 | )], 217 | ), 218 | ), 219 | iam.Policy( 220 | PolicyName="ECSManagementPolicy", 221 | PolicyDocument=dict( 222 | Statement=[dict( 223 | Effect="Allow", 224 | Action=[ 225 | "ecs:*", 226 | "elasticloadbalancing:*", 227 | ], 228 | Resource="*", 229 | )], 230 | ), 231 | ), 232 | iam.Policy( 233 | PolicyName='ECRManagementPolicy', 234 | PolicyDocument=dict( 235 | Statement=[dict( 236 | Effect='Allow', 237 | Action=[ 238 | ecr.GetAuthorizationToken, 239 | ecr.GetDownloadUrlForLayer, 240 | ecr.BatchGetImage, 241 | ecr.BatchCheckLayerAvailability, 242 | ], 243 | Resource="*", 244 | )], 245 | ), 246 | ), 247 | iam.Policy( 248 | PolicyName="LoggingPolicy", 249 | PolicyDocument=dict( 250 | Statement=[dict( 251 | Effect="Allow", 252 | Action=[ 253 | "logs:Create*", 254 | "logs:PutLogEvents", 255 | ], 256 | Resource="arn:aws:logs:*:*:*", 257 | )], 258 | ), 259 | ), 260 | ] 261 | ) 262 | 263 | 264 | # ECS container instance profile 265 | container_instance_profile = iam.InstanceProfile( 266 | "ContainerInstanceProfile", 267 | template=template, 268 | Path="/", 269 | Roles=[Ref(container_instance_role)], 270 | ) 271 | 272 | 273 | container_security_group = SecurityGroup( 274 | 'ContainerSecurityGroup', 275 | template=template, 276 | GroupDescription="Container security group.", 277 | VpcId=Ref(vpc), 278 | SecurityGroupIngress=[ 279 | # HTTP from web public subnets 280 | SecurityGroupRule( 281 | IpProtocol="tcp", 282 | FromPort=web_worker_port, 283 | ToPort=web_worker_port, 284 | CidrIp=loadbalancer_a_subnet_cidr, 285 | ), 286 | SecurityGroupRule( 287 | IpProtocol="tcp", 288 | FromPort=web_worker_port, 289 | ToPort=web_worker_port, 290 | CidrIp=loadbalancer_b_subnet_cidr, 291 | ), 292 | ], 293 | ) 294 | 295 | 296 | container_instance_configuration_name = "ContainerLaunchConfiguration" 297 | 298 | 299 | autoscaling_group_name = "AutoScalingGroup" 300 | 301 | 302 | container_instance_configuration = autoscaling.LaunchConfiguration( 303 | container_instance_configuration_name, 304 | template=template, 305 | Metadata=autoscaling.Metadata( 306 | cloudformation.Init(dict( 307 | config=cloudformation.InitConfig( 308 | commands=dict( 309 | register_cluster=dict(command=Join("", [ 310 | "#!/bin/bash\n", 311 | # Register the cluster 312 | "echo ECS_CLUSTER=", 313 | Ref(cluster), 314 | " >> /etc/ecs/ecs.config\n", 315 | # Enable CloudWatch docker logging 316 | 'echo \'ECS_AVAILABLE_LOGGING_DRIVERS=', 317 | '["json-file","awslogs"]\'', 318 | " >> /etc/ecs/ecs.config\n", 319 | ])) 320 | ), 321 | files=cloudformation.InitFiles({ 322 | "/etc/cfn/cfn-hup.conf": cloudformation.InitFile( 323 | content=Join("", [ 324 | "[main]\n", 325 | "stack=", 326 | Ref(AWS_STACK_ID), 327 | "\n", 328 | "region=", 329 | Ref(AWS_REGION), 330 | "\n", 331 | ]), 332 | mode="000400", 333 | owner="root", 334 | group="root", 335 | ), 336 | "/etc/cfn/hooks.d/cfn-auto-reloader.conf": 337 | cloudformation.InitFile( 338 | content=Join("", [ 339 | "[cfn-auto-reloader-hook]\n", 340 | "triggers=post.update\n", 341 | "path=Resources.%s." 342 | % container_instance_configuration_name, 343 | "Metadata.AWS::CloudFormation::Init\n", 344 | "action=/opt/aws/bin/cfn-init -v ", 345 | " --stack ", 346 | Ref(AWS_STACK_NAME), 347 | " --resource %s" 348 | % container_instance_configuration_name, 349 | " --region ", 350 | Ref("AWS::Region"), 351 | "\n", 352 | "runas=root\n", 353 | ]) 354 | ) 355 | }), 356 | services=dict( 357 | sysvinit=cloudformation.InitServices({ 358 | 'cfn-hup': cloudformation.InitService( 359 | enabled=True, 360 | ensureRunning=True, 361 | files=[ 362 | "/etc/cfn/cfn-hup.conf", 363 | "/etc/cfn/hooks.d/cfn-auto-reloader.conf", 364 | ] 365 | ), 366 | }) 367 | ) 368 | ) 369 | )) 370 | ), 371 | SecurityGroups=[Ref(container_security_group)], 372 | InstanceType=container_instance_type, 373 | ImageId=FindInMap("ECSRegionMap", Ref(AWS_REGION), "AMI"), 374 | IamInstanceProfile=Ref(container_instance_profile), 375 | UserData=Base64(Join('', [ 376 | "#!/bin/bash -xe\n", 377 | "yum install -y aws-cfn-bootstrap\n", 378 | "/opt/aws/bin/cfn-init -v ", 379 | " --stack ", Ref(AWS_STACK_NAME), 380 | " --resource %s " % container_instance_configuration_name, 381 | " --region ", Ref(AWS_REGION), "\n", 382 | "/opt/aws/bin/cfn-signal -e $? ", 383 | " --stack ", Ref(AWS_STACK_NAME), 384 | " --resource %s " % container_instance_configuration_name, 385 | " --region ", Ref(AWS_REGION), "\n", 386 | ])), 387 | ) 388 | 389 | 390 | autoscaling_group = autoscaling.AutoScalingGroup( 391 | autoscaling_group_name, 392 | template=template, 393 | VPCZoneIdentifier=[Ref(container_a_subnet), Ref(container_b_subnet)], 394 | MinSize=desired_container_instances, 395 | MaxSize=max_container_instances, 396 | DesiredCapacity=desired_container_instances, 397 | LaunchConfigurationName=Ref(container_instance_configuration), 398 | HealthCheckType="EC2", 399 | HealthCheckGracePeriod=300, 400 | ) 401 | 402 | 403 | app_service_role = iam.Role( 404 | "AppServiceRole", 405 | template=template, 406 | AssumeRolePolicyDocument=dict(Statement=[dict( 407 | Effect="Allow", 408 | Principal=dict(Service=["ecs.amazonaws.com"]), 409 | Action=["sts:AssumeRole"], 410 | )]), 411 | Path="/", 412 | Policies=[ 413 | iam.Policy( 414 | PolicyName="WebServicePolicy", 415 | PolicyDocument=dict( 416 | Statement=[dict( 417 | Effect="Allow", 418 | Action=[ 419 | "elasticloadbalancing:Describe*", 420 | "elasticloadbalancing" 421 | ":DeregisterInstancesFromLoadBalancer", 422 | "elasticloadbalancing" 423 | ":RegisterInstancesWithLoadBalancer", 424 | "ec2:Describe*", 425 | "ec2:AuthorizeSecurityGroupIngress", 426 | ], 427 | Resource="*", 428 | )], 429 | ), 430 | ), 431 | ] 432 | ) 433 | -------------------------------------------------------------------------------- /stack/database.py: -------------------------------------------------------------------------------- 1 | from troposphere import ( 2 | ec2, 3 | Parameter, 4 | rds, 5 | Ref, 6 | AWS_STACK_NAME, 7 | ) 8 | 9 | from .template import template 10 | from .vpc import ( 11 | vpc, 12 | container_a_subnet, 13 | container_a_subnet_cidr, 14 | container_b_subnet, 15 | container_b_subnet_cidr, 16 | ) 17 | 18 | 19 | db_name = template.add_parameter(Parameter( 20 | "DatabaseName", 21 | Default="app", 22 | Description="The database name", 23 | Type="String", 24 | MinLength="1", 25 | MaxLength="64", 26 | AllowedPattern="[a-zA-Z][a-zA-Z0-9]*", 27 | ConstraintDescription=( 28 | "must begin with a letter and contain only" 29 | " alphanumeric characters." 30 | ) 31 | )) 32 | 33 | 34 | db_user = template.add_parameter(Parameter( 35 | "DatabaseUser", 36 | Default="app", 37 | Description="The database admin account username", 38 | Type="String", 39 | MinLength="1", 40 | MaxLength="16", 41 | AllowedPattern="[a-zA-Z][a-zA-Z0-9]*", 42 | ConstraintDescription=( 43 | "must begin with a letter and contain only" 44 | " alphanumeric characters." 45 | ) 46 | )) 47 | 48 | 49 | db_password = template.add_parameter(Parameter( 50 | "DatabasePassword", 51 | NoEcho=True, 52 | Description="The database admin account password", 53 | Type="String", 54 | MinLength="10", 55 | MaxLength="41", 56 | AllowedPattern="[a-zA-Z0-9]*", 57 | ConstraintDescription="must contain only alphanumeric characters." 58 | )) 59 | 60 | db_class = template.add_parameter(Parameter( 61 | "DatabaseClass", 62 | Default="db.t2.small", 63 | Description="Database instance class", 64 | Type="String", 65 | AllowedValues=['db.t2.small', 'db.t2.medium'], 66 | ConstraintDescription="must select a valid database instance type.", 67 | )) 68 | 69 | 70 | db_allocated_storage = template.add_parameter(Parameter( 71 | "DatabaseAllocatedStorage", 72 | Default="5", 73 | Description="The size of the database (Gb)", 74 | Type="Number", 75 | MinValue="5", 76 | MaxValue="1024", 77 | ConstraintDescription="must be between 5 and 1024Gb.", 78 | )) 79 | 80 | 81 | db_security_group = ec2.SecurityGroup( 82 | 'DatabaseSecurityGroup', 83 | template=template, 84 | GroupDescription="Database security group.", 85 | VpcId=Ref(vpc), 86 | SecurityGroupIngress=[ 87 | # Postgres in from web clusters 88 | ec2.SecurityGroupRule( 89 | IpProtocol="tcp", 90 | FromPort="5432", 91 | ToPort="5432", 92 | CidrIp=container_a_subnet_cidr, 93 | ), 94 | ec2.SecurityGroupRule( 95 | IpProtocol="tcp", 96 | FromPort="5432", 97 | ToPort="5432", 98 | CidrIp=container_b_subnet_cidr, 99 | ), 100 | ], 101 | ) 102 | 103 | 104 | db_subnet_group = rds.DBSubnetGroup( 105 | "DatabaseSubnetGroup", 106 | template=template, 107 | DBSubnetGroupDescription="Subnets available for the RDS DB Instance", 108 | SubnetIds=[Ref(container_a_subnet), Ref(container_b_subnet)], 109 | ) 110 | 111 | 112 | db_instance = rds.DBInstance( 113 | "PostgreSQL", 114 | template=template, 115 | DBName=Ref(db_name), 116 | AllocatedStorage=Ref(db_allocated_storage), 117 | DBInstanceClass=Ref(db_class), 118 | DBInstanceIdentifier=Ref(AWS_STACK_NAME), 119 | Engine="postgres", 120 | EngineVersion="9.4.5", 121 | MultiAZ=True, 122 | StorageType="gp2", 123 | MasterUsername=Ref(db_user), 124 | MasterUserPassword=Ref(db_password), 125 | DBSubnetGroupName=Ref(db_subnet_group), 126 | VPCSecurityGroups=[Ref(db_security_group)], 127 | BackupRetentionPeriod="7", 128 | DeletionPolicy="Snapshot", 129 | ) 130 | -------------------------------------------------------------------------------- /stack/domain.py: -------------------------------------------------------------------------------- 1 | from troposphere import ( 2 | Parameter, 3 | Ref, 4 | ) 5 | 6 | from .template import template 7 | 8 | 9 | domain_name = Ref(template.add_parameter(Parameter( 10 | "DomainName", 11 | Description="The domain name", 12 | Type="String", 13 | ))) 14 | -------------------------------------------------------------------------------- /stack/repository.py: -------------------------------------------------------------------------------- 1 | from troposphere import ( 2 | AWS_ACCOUNT_ID, 3 | AWS_REGION, 4 | AWS_STACK_NAME, 5 | Join, 6 | Ref, 7 | Output, 8 | ) 9 | from troposphere.ecr import Repository 10 | from awacs.aws import ( 11 | Allow, 12 | Policy, 13 | AWSPrincipal, 14 | Statement, 15 | ) 16 | import awacs.ecr as ecr 17 | 18 | from .template import template 19 | 20 | 21 | # Create an `ECR` docker repository 22 | repository = Repository( 23 | "ApplicationRepository", 24 | template=template, 25 | RepositoryName=Ref(AWS_STACK_NAME), 26 | # Allow all account users to manage images. 27 | RepositoryPolicyText=Policy( 28 | Version="2008-10-17", 29 | Statement=[ 30 | Statement( 31 | Sid="AllowPushPull", 32 | Effect=Allow, 33 | Principal=AWSPrincipal([ 34 | Join("", [ 35 | "arn:aws:iam::", 36 | Ref(AWS_ACCOUNT_ID), 37 | ":root", 38 | ]), 39 | ]), 40 | Action=[ 41 | ecr.GetDownloadUrlForLayer, 42 | ecr.BatchGetImage, 43 | ecr.BatchCheckLayerAvailability, 44 | ecr.PutImage, 45 | ecr.InitiateLayerUpload, 46 | ecr.UploadLayerPart, 47 | ecr.CompleteLayerUpload, 48 | ], 49 | ), 50 | ] 51 | ), 52 | ) 53 | 54 | 55 | # Output ECR repository URL 56 | template.add_output(Output( 57 | "RepositoryURL", 58 | Description="The docker repository URL", 59 | Value=Join("", [ 60 | Ref(AWS_ACCOUNT_ID), 61 | ".dkr.ecr.", 62 | Ref(AWS_REGION), 63 | ".amazonaws.com/", 64 | Ref(repository), 65 | ]), 66 | )) 67 | -------------------------------------------------------------------------------- /stack/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/ecs/4aeaebb0df8980c1f7327203df757314fb961506/stack/services/__init__.py -------------------------------------------------------------------------------- /stack/services/application.py: -------------------------------------------------------------------------------- 1 | from troposphere import ( 2 | AWS_ACCOUNT_ID, 3 | AWS_REGION, 4 | Equals, 5 | GetAtt, 6 | iam, 7 | Join, 8 | logs, 9 | Not, 10 | Output, 11 | Parameter, 12 | Ref, 13 | ) 14 | 15 | from troposphere.ecs import ( 16 | ContainerDefinition, 17 | DeploymentConfiguration, 18 | Environment, 19 | LoadBalancer, 20 | LogConfiguration, 21 | PortMapping, 22 | Service, 23 | TaskDefinition, 24 | ) 25 | 26 | 27 | from ..assets import ( 28 | assets_bucket, 29 | distribution, 30 | ) 31 | from ..cluster import ( 32 | autoscaling_group_name, 33 | application_listener, 34 | cluster, 35 | application_target_group, 36 | web_worker_port, 37 | ) 38 | from ..repository import repository 39 | from ..template import template 40 | from ..domain import domain_name 41 | from ..database import ( 42 | db_instance, 43 | db_name, 44 | db_user, 45 | db_password, 46 | ) 47 | 48 | 49 | application_revision = Ref(template.add_parameter(Parameter( 50 | "WebAppRevision", 51 | Description="An optional docker app revision to deploy", 52 | Type="String", 53 | Default="", 54 | ))) 55 | 56 | 57 | secret_key = Ref(template.add_parameter(Parameter( 58 | "SecretKey", 59 | Description="Application secret key", 60 | Type="String", 61 | ))) 62 | 63 | 64 | web_worker_cpu = Ref(template.add_parameter(Parameter( 65 | "WebWorkerCPU", 66 | Description="Web worker CPU units", 67 | Type="Number", 68 | Default="256", 69 | ))) 70 | 71 | 72 | web_worker_memory = Ref(template.add_parameter(Parameter( 73 | "WebWorkerMemory", 74 | Description="Web worker memory", 75 | Type="Number", 76 | Default="500", 77 | ))) 78 | 79 | 80 | web_worker_desired_count = Ref(template.add_parameter(Parameter( 81 | "WebWorkerDesiredCount", 82 | Description="Web worker task instance count", 83 | Type="Number", 84 | Default="3", 85 | ))) 86 | 87 | 88 | deploy_condition = "Deploy" 89 | template.add_condition(deploy_condition, Not(Equals(application_revision, ""))) 90 | 91 | 92 | image = Join("", [ 93 | Ref(AWS_ACCOUNT_ID), 94 | ".dkr.ecr.", 95 | Ref(AWS_REGION), 96 | ".amazonaws.com/", 97 | Ref(repository), 98 | ":", 99 | application_revision, 100 | ]) 101 | 102 | 103 | web_log_group = logs.LogGroup( 104 | "WebLogs", 105 | template=template, 106 | RetentionInDays=365, 107 | DeletionPolicy="Retain", 108 | ) 109 | 110 | 111 | template.add_output(Output( 112 | "WebLogsGroup", 113 | Description="Web application log group", 114 | Value=GetAtt(web_log_group, "Arn") 115 | )) 116 | 117 | log_configuration = LogConfiguration( 118 | LogDriver="awslogs", 119 | Options={ 120 | 'awslogs-group': Ref(web_log_group), 121 | 'awslogs-region': Ref(AWS_REGION), 122 | } 123 | ) 124 | 125 | 126 | # ECS task 127 | web_task_definition = TaskDefinition( 128 | "WebTask", 129 | template=template, 130 | Condition=deploy_condition, 131 | ContainerDefinitions=[ 132 | ContainerDefinition( 133 | Name="WebWorker", 134 | # 1024 is full CPU 135 | Cpu=web_worker_cpu, 136 | Memory=web_worker_memory, 137 | Essential=True, 138 | Image=Join("", [ 139 | Ref(AWS_ACCOUNT_ID), 140 | ".dkr.ecr.", 141 | Ref(AWS_REGION), 142 | ".amazonaws.com/", 143 | Ref(repository), 144 | ":", 145 | application_revision, 146 | ]), 147 | PortMappings=[PortMapping( 148 | HostPort=0, 149 | ContainerPort=web_worker_port, 150 | )], 151 | LogConfiguration=LogConfiguration( 152 | LogDriver="awslogs", 153 | Options={ 154 | 'awslogs-group': Ref(web_log_group), 155 | 'awslogs-region': Ref(AWS_REGION), 156 | } 157 | ), 158 | Environment=[ 159 | Environment( 160 | Name="AWS_STORAGE_BUCKET_NAME", 161 | Value=Ref(assets_bucket), 162 | ), 163 | Environment( 164 | Name="CDN_DOMAIN_NAME", 165 | Value=GetAtt(distribution, "DomainName"), 166 | ), 167 | Environment( 168 | Name="DOMAIN_NAME", 169 | Value=domain_name, 170 | ), 171 | Environment( 172 | Name="PORT", 173 | Value=web_worker_port, 174 | ), 175 | Environment( 176 | Name="SECRET_KEY", 177 | Value=secret_key, 178 | ), 179 | Environment( 180 | Name="DATABASE_URL", 181 | Value=Join("", [ 182 | "postgres://", 183 | Ref(db_user), 184 | ":", 185 | Ref(db_password), 186 | "@", 187 | GetAtt(db_instance, 'Endpoint.Address'), 188 | "/", 189 | Ref(db_name), 190 | ]), 191 | ), 192 | ], 193 | ) 194 | ], 195 | ) 196 | 197 | 198 | application_service_role = iam.Role( 199 | "ApplicationServiceRole", 200 | template=template, 201 | AssumeRolePolicyDocument=dict(Statement=[dict( 202 | Effect="Allow", 203 | Principal=dict(Service=["ecs.amazonaws.com"]), 204 | Action=["sts:AssumeRole"], 205 | )]), 206 | Path="/", 207 | Policies=[ 208 | iam.Policy( 209 | PolicyName="WebServicePolicy", 210 | PolicyDocument=dict( 211 | Statement=[dict( 212 | Effect="Allow", 213 | Action=[ 214 | "elasticloadbalancing:Describe*", 215 | "elasticloadbalancing:RegisterTargets", 216 | "elasticloadbalancing:DeregisterTargets", 217 | "elasticloadbalancing" 218 | ":DeregisterInstancesFromLoadBalancer", 219 | "elasticloadbalancing" 220 | ":RegisterInstancesWithLoadBalancer", 221 | "ec2:Describe*", 222 | "ec2:AuthorizeSecurityGroupIngress", 223 | ], 224 | Resource="*", 225 | )], 226 | ), 227 | ), 228 | ] 229 | ) 230 | 231 | 232 | application_service = Service( 233 | "ApplicationService", 234 | template=template, 235 | Cluster=Ref(cluster), 236 | Condition=deploy_condition, 237 | DependsOn=[autoscaling_group_name, application_listener.title], 238 | DeploymentConfiguration=DeploymentConfiguration( 239 | MaximumPercent=135, 240 | MinimumHealthyPercent=30, 241 | ), 242 | DesiredCount=web_worker_desired_count, 243 | LoadBalancers=[LoadBalancer( 244 | ContainerName="WebWorker", 245 | ContainerPort=web_worker_port, 246 | TargetGroupArn=Ref(application_target_group), 247 | )], 248 | TaskDefinition=Ref(web_task_definition), 249 | Role=Ref(application_service_role), 250 | ) 251 | -------------------------------------------------------------------------------- /stack/template.py: -------------------------------------------------------------------------------- 1 | from troposphere import ( 2 | Template, 3 | ) 4 | 5 | # The CloudFormation template 6 | template = Template() 7 | -------------------------------------------------------------------------------- /stack/vpc.py: -------------------------------------------------------------------------------- 1 | from troposphere import ( 2 | AWS_REGION, 3 | GetAtt, 4 | Join, 5 | Ref, 6 | ) 7 | 8 | from troposphere.ec2 import ( 9 | EIP, 10 | InternetGateway, 11 | NatGateway, 12 | Route, 13 | RouteTable, 14 | Subnet, 15 | SubnetRouteTableAssociation, 16 | VPC, 17 | VPCGatewayAttachment, 18 | ) 19 | 20 | from .template import template 21 | 22 | 23 | vpc = VPC( 24 | "Vpc", 25 | template=template, 26 | CidrBlock="10.0.0.0/16", 27 | ) 28 | 29 | 30 | # Allow outgoing to outside VPC 31 | internet_gateway = InternetGateway( 32 | "InternetGateway", 33 | template=template, 34 | ) 35 | 36 | 37 | # Attach Gateway to VPC 38 | VPCGatewayAttachment( 39 | "GatewayAttachement", 40 | template=template, 41 | VpcId=Ref(vpc), 42 | InternetGatewayId=Ref(internet_gateway), 43 | ) 44 | 45 | 46 | # Public route table 47 | public_route_table = RouteTable( 48 | "PublicRouteTable", 49 | template=template, 50 | VpcId=Ref(vpc), 51 | ) 52 | 53 | 54 | public_route = Route( 55 | "PublicRoute", 56 | template=template, 57 | GatewayId=Ref(internet_gateway), 58 | DestinationCidrBlock="0.0.0.0/0", 59 | RouteTableId=Ref(public_route_table), 60 | ) 61 | 62 | 63 | # Holds public instances 64 | public_subnet_cidr = "10.0.1.0/24" 65 | 66 | public_subnet = Subnet( 67 | "PublicSubnet", 68 | template=template, 69 | VpcId=Ref(vpc), 70 | CidrBlock=public_subnet_cidr, 71 | ) 72 | 73 | 74 | SubnetRouteTableAssociation( 75 | "PublicSubnetRouteTableAssociation", 76 | template=template, 77 | RouteTableId=Ref(public_route_table), 78 | SubnetId=Ref(public_subnet), 79 | ) 80 | 81 | 82 | # NAT 83 | nat_ip = EIP( 84 | "NatIp", 85 | template=template, 86 | Domain="vpc", 87 | ) 88 | 89 | 90 | nat_gateway = NatGateway( 91 | "NatGateway", 92 | template=template, 93 | AllocationId=GetAtt(nat_ip, "AllocationId"), 94 | SubnetId=Ref(public_subnet), 95 | ) 96 | 97 | 98 | # Holds load balancer 99 | loadbalancer_a_subnet_cidr = "10.0.2.0/24" 100 | loadbalancer_a_subnet = Subnet( 101 | "LoadbalancerASubnet", 102 | template=template, 103 | VpcId=Ref(vpc), 104 | CidrBlock=loadbalancer_a_subnet_cidr, 105 | AvailabilityZone=Join("", [Ref(AWS_REGION), "a"]), 106 | ) 107 | 108 | 109 | SubnetRouteTableAssociation( 110 | "LoadbalancerASubnetRouteTableAssociation", 111 | template=template, 112 | RouteTableId=Ref(public_route_table), 113 | SubnetId=Ref(loadbalancer_a_subnet), 114 | ) 115 | 116 | 117 | loadbalancer_b_subnet_cidr = "10.0.3.0/24" 118 | loadbalancer_b_subnet = Subnet( 119 | "LoadbalancerBSubnet", 120 | template=template, 121 | VpcId=Ref(vpc), 122 | CidrBlock=loadbalancer_b_subnet_cidr, 123 | AvailabilityZone=Join("", [Ref(AWS_REGION), "b"]), 124 | ) 125 | 126 | 127 | SubnetRouteTableAssociation( 128 | "LoadbalancerBSubnetRouteTableAssociation", 129 | template=template, 130 | RouteTableId=Ref(public_route_table), 131 | SubnetId=Ref(loadbalancer_b_subnet), 132 | ) 133 | 134 | 135 | # Private route table 136 | private_route_table = RouteTable( 137 | "PrivateRouteTable", 138 | template=template, 139 | VpcId=Ref(vpc), 140 | ) 141 | 142 | 143 | private_nat_route = Route( 144 | "PrivateNatRoute", 145 | template=template, 146 | RouteTableId=Ref(private_route_table), 147 | DestinationCidrBlock="0.0.0.0/0", 148 | NatGatewayId=Ref(nat_gateway), 149 | ) 150 | 151 | 152 | # Holds containers instances 153 | container_a_subnet_cidr = "10.0.10.0/24" 154 | container_a_subnet = Subnet( 155 | "ContainerASubnet", 156 | template=template, 157 | VpcId=Ref(vpc), 158 | CidrBlock=container_a_subnet_cidr, 159 | AvailabilityZone=Join("", [Ref(AWS_REGION), "a"]), 160 | ) 161 | 162 | 163 | SubnetRouteTableAssociation( 164 | "ContainerARouteTableAssociation", 165 | template=template, 166 | SubnetId=Ref(container_a_subnet), 167 | RouteTableId=Ref(private_route_table), 168 | ) 169 | 170 | 171 | container_b_subnet_cidr = "10.0.11.0/24" 172 | container_b_subnet = Subnet( 173 | "ContainerBSubnet", 174 | template=template, 175 | VpcId=Ref(vpc), 176 | CidrBlock=container_b_subnet_cidr, 177 | AvailabilityZone=Join("", [Ref(AWS_REGION), "b"]), 178 | ) 179 | 180 | 181 | SubnetRouteTableAssociation( 182 | "ContainerBRouteTableAssociation", 183 | template=template, 184 | SubnetId=Ref(container_b_subnet), 185 | RouteTableId=Ref(private_route_table), 186 | ) 187 | --------------------------------------------------------------------------------