diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 0000000..a4775ea --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,43 @@ +# This workflow installs the latest version of Terraform CLI. On pull request events, this workflow will run +# `terraform init`, `terraform fmt`, and `terraform plan`. +# +# Documentation for `hashicorp/setup-terraform` is located here: https://github.com/hashicorp/setup-terraform + +name: 'Terraform' + +on: + push: + +permissions: + contents: read + +jobs: + terraform: + name: 'Terraform' + runs-on: ubuntu-latest + + # Use the Bash shell regardless whether the GitHub Actions runner is ubuntu-latest, macos-latest, or windows-latest + defaults: + run: + shell: bash + + steps: + # Checkout the repository to the GitHub Actions runner + - name: Checkout + uses: actions/checkout@v3 + + # Install the latest version of Terraform CLI + - name: Setup Terraform + uses: hashicorp/setup-terraform@v2 + + # Checks that all Terraform configuration files adhere to a canonical format + - name: Terraform Format + run: terraform fmt -check -diff -recursive + + # Initialize a new or existing Terraform working directory by creating initial files, loading any remote state, downloading modules, etc. + - name: Terraform Init + run: terraform -chdir=test init + + # Validate the files, referring only to the configuration and not accessing any remote services + - name: Terraform Validate + run: terraform -chdir=test validate diff --git a/.gitignore b/.gitignore index 9b8a46e..08fde58 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ override.tf.json # Ignore CLI configuration files .terraformrc terraform.rc + +# IDE configuration +.idea/ diff --git a/README.md b/README.md index ac09dd2..3764bfb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,14 @@ # terraform-aws-ecs-app Terraform module to host an app on AWS ECS + +Includes: + +* VPC - Virtual Private Cloud +* ALB - Application Load Balancer +* ASG - Autoscaling Group +* ECS (Elastic Container Service) Cluster and Service +* ECR - Elastic Container Registry +* RDS (Relational Database Service) Instance +* CloudWatch Dashboard (optional) +* Cloudflare DNS Record (optional) +* Adminer database manager (optional) diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..406ce3e --- /dev/null +++ b/main.tf @@ -0,0 +1,246 @@ +locals { + app_name_and_env = "${var.app_name}-${local.app_env}" + app_env = var.app_env + + db_host = module.rds.address + db_password = random_password.db_root.result + + account = data.aws_caller_identity.this.account_id + region = data.aws_region.current.name +} + +/* + * Create user for CI/CD to perform ECS actions + */ +resource "aws_iam_user" "cd" { + count = var.create_cd_user ? 1 : 0 + + name = "cd-${local.app_name_and_env}" +} + +resource "aws_iam_access_key" "cd" { + count = var.create_cd_user ? 1 : 0 + + user = aws_iam_user.cd[0].name +} + +resource "aws_iam_user_policy" "cd" { + count = var.create_cd_user ? 1 : 0 + + name = "ecs_deployment" + user = aws_iam_user.cd[0].name + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "ecr:GetAuthorizationToken", + "ecs:DeregisterTaskDefinition", + "ecs:DescribeTaskDefinition", + "ecs:ListTaskDefinitions", + "ecs:RegisterTaskDefinition", + ], + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "ecs:DescribeServices", + "ecs:UpdateService", + ] + Resource = "arn:aws:ecs:${local.region}:${local.account}:service/${module.ecsasg.ecs_cluster_name}/${module.ecs.service_name}" + }, + { + Effect = "Allow" + Action = [ + "ecs:DescribeTasks", + "ecs:StopTask", + ] + Resource = "arn:aws:ecs:${local.region}:${local.account}:task/${module.ecsasg.ecs_cluster_name}/*" + }, + { + Effect = "Allow" + Action = [ + "ecs:ListTasks", + ] + "Effect" : "Allow", + "Condition" : { + "ArnEquals" : { + "ecs:cluster" : "arn:aws:ecs:${local.region}:${local.account}:cluster/${module.ecsasg.ecs_cluster_name}" + } + } + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "ecs:StartTask", + ] + Resource = "arn:aws:ecs:${local.region}:${local.account}:task-definition/${module.ecs.task_def_family}:*" + }, + { + Effect = "Allow" + Action = [ + "iam:PassRole", + ] + Resource = module.ecsasg.ecsServiceRole_arn + }, + ] + }) +} + +/* + * Create Cloudwatch log group + */ +resource "aws_cloudwatch_log_group" "logs" { + name = local.app_name_and_env + retention_in_days = 30 +} + +/* + * Create target group for ALB + */ +resource "aws_alb_target_group" "tg" { + name = substr("tg-${local.app_name_and_env}", 0, 32) + port = "80" + protocol = "HTTP" + vpc_id = module.vpc.id + deregistration_delay = "30" + + stickiness { + type = "lb_cookie" + } + + health_check { + path = "/" + matcher = "302" + } +} + +/* + * Create listener rule for hostname routing to new target group + */ +resource "aws_alb_listener_rule" "tg" { + listener_arn = module.alb.https_listener_arn + priority = "218" + + action { + type = "forward" + target_group_arn = aws_alb_target_group.tg.arn + } + + condition { + host_header { + values = ["${var.subdomain}.${var.domain_name}"] + } + } +} + +/* + * Create cloudwatch dashboard for service + */ +module "ecs-service-cloudwatch-dashboard" { + count = var.create_dashboard ? 1 : 0 + + source = "silinternational/ecs-service-cloudwatch-dashboard/aws" + version = "~> 3.0.1" + + cluster_name = module.ecsasg.ecs_cluster_name + dashboard_name = local.app_name_and_env + service_names = [var.app_name] +} + +/* + * Create RDS root password + */ +resource "random_password" "db_root" { + length = 16 +} + +/* + * Create an RDS database + */ +module "rds" { + source = "github.com/silinternational/terraform-modules//aws/rds/mariadb?ref=8.2.1" + + app_name = var.app_name + app_env = local.app_env + db_name = var.database_name + db_root_user = var.database_user + db_root_pass = random_password.db_root.result + subnet_group_name = module.vpc.db_subnet_group_name + security_groups = [module.vpc.vpc_default_sg_id] + + allocated_storage = 20 // 20 gibibyte + instance_class = "db.t3.micro" + multi_az = true +} + +/* + * Optional Adminer database manager + */ +module "adminer" { + count = var.create_adminer ? 1 : 0 + source = "silinternational/adminer/aws" + version = "1.0.2" + + adminer_default_server = module.rds.address + app_name = var.app_name + app_env = var.app_env + vpc_id = module.vpc.id + alb_https_listener_arn = module.alb.https_listener_arn + subdomain = "adminer" + cloudflare_domain = var.domain_name + ecs_cluster_id = module.ecsasg.ecs_cluster_id + ecsServiceRole_arn = module.ecsasg.ecsServiceRole_arn + alb_dns_name = module.alb.dns_name + enable = var.enable_adminer +} + +/* + * Create new ecs service + */ +module "ecs" { + source = "github.com/silinternational/terraform-modules//aws/ecs/service-only?ref=8.2.1" + cluster_id = module.ecsasg.ecs_cluster_id + service_name = var.app_name + service_env = local.app_env + container_def_json = var.container_def_json + desired_count = var.desired_count + tg_arn = aws_alb_target_group.tg.arn + lb_container_name = "hub" + lb_container_port = "80" + ecsServiceRole_arn = module.ecsasg.ecsServiceRole_arn +} + +/* + * Create Cloudflare DNS record + */ +resource "cloudflare_record" "dns" { + count = var.create_dns_record ? 1 : 0 + + zone_id = data.cloudflare_zones.domain.zones[0].id + name = var.subdomain + value = module.alb.dns_name + type = "CNAME" + proxied = true +} + +data "cloudflare_zones" "domain" { + filter { + name = var.domain_name + lookup_type = "exact" + status = "active" + } +} + + +/* + * AWS data + */ + +data "aws_caller_identity" "this" {} + +data "aws_region" "current" {} diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 0000000..ebf0ac9 --- /dev/null +++ b/outputs.tf @@ -0,0 +1,42 @@ + +output "app_url" { + value = "https://${var.subdomain}.${var.domain_name}" +} + +output "cloudwatch_log_group_name" { + value = aws_cloudwatch_log_group.logs.name +} + +output "database_host" { + value = module.rds.address +} + +output "database_password" { + value = random_password.db_root.result + sensitive = true +} + +output "adminer_url" { + value = one(module.adminer[*].adminer_url) +} + +output "ecsInstanceRole_arn" { + value = module.ecsasg.ecsInstanceRole_arn +} + +output "ecsServiceRole_arn" { + value = module.ecsasg.ecsServiceRole_arn +} + +output "cd_user_arn" { + value = one(aws_iam_user.cd[*].arn) +} + +output "cd_user_access_key_id" { + value = one(aws_iam_access_key.cd[*].id) +} + +output "cd_user_secret_access_key_id" { + value = one(aws_iam_access_key.cd[*].secret) + sensitive = true +} diff --git a/test/.terraform.lock.hcl b/test/.terraform.lock.hcl new file mode 100644 index 0000000..7fb8352 --- /dev/null +++ b/test/.terraform.lock.hcl @@ -0,0 +1,88 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/cloudflare/cloudflare" { + version = "3.35.0" + constraints = "~> 3.7" + hashes = [ + "h1:SFvdgX5bTGhOTMhywgjSOWlkET2el7STxdUSzxjz2pc=", + "zh:13aabc00fee823422831bcc870227650cc765fc4c9622074d24d6d62a4ac0e37", + "zh:1544405f0ea6b388dad7eb25c434427b2682417396da9186e1b33551e6b4adff", + "zh:5d58394cb8e71bd4bf6ef0135f1ca6a4ad2cec937f3731b224125eb34ee059f7", + "zh:648596ed545ed01ae757d5a0b37c20e8050cfb51d42e9a2c82fcc94d883ff11d", + "zh:68d75e14eef4f073faa975ed6baf4db7e0e1f2fc61a4e54fd95325df42793810", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:9916cc626fef57428c4c60db7897b34068c65639b68482e94f62d97d773d64bc", + "zh:9c8c9f369eb30e7360a0ebd7918e4846ca4d5bca430b861fdbde7522a3146459", + "zh:a40e244688bbcb6f1a771e6ea89fb0b0b7bb53be3fab718abc66b3593e0f8133", + "zh:cc5a6191aa8713275550ff2b6adda6e6d56e4780c9cbe3d1da1dc23ea893bfff", + "zh:d1dd435780e8c7e79bff26b46a76df0e123971849355ad17877d1e24dc5953c3", + "zh:d751fc72f2833f2bdb897fa89de2bb5b6efbad1e648896642f0e6fe5cde789c8", + "zh:dfc4c90b3605ec1bb7cc7a9f1fb1b67235578bdd6b9be78e7b3516b55d0422db", + "zh:e6101a80fe24e2df3ab60152458ff1666a4a1befc87c62e459a219cdbb53e6df", + "zh:e9bcf26c44dd231f74703b6a6717470021a3ba7e1d7531dcf7287a6441300e27", + ] +} + +provider "registry.terraform.io/hashicorp/aws" { + version = "4.67.0" + constraints = ">= 2.0.0, >= 4.0.0, ~> 4.0, < 5.0.0" + hashes = [ + "h1:dCRc4GqsyfqHEMjgtlM1EympBcgTmcTkWaJmtd91+KA=", + "zh:0843017ecc24385f2b45f2c5fce79dc25b258e50d516877b3affee3bef34f060", + "zh:19876066cfa60de91834ec569a6448dab8c2518b8a71b5ca870b2444febddac6", + "zh:24995686b2ad88c1ffaa242e36eee791fc6070e6144f418048c4ce24d0ba5183", + "zh:4a002990b9f4d6d225d82cb2fb8805789ffef791999ee5d9cb1fef579aeff8f1", + "zh:559a2b5ace06b878c6de3ecf19b94fbae3512562f7a51e930674b16c2f606e29", + "zh:6a07da13b86b9753b95d4d8218f6dae874cf34699bca1470d6effbb4dee7f4b7", + "zh:768b3bfd126c3b77dc975c7c0e5db3207e4f9997cf41aa3385c63206242ba043", + "zh:7be5177e698d4b547083cc738b977742d70ed68487ce6f49ecd0c94dbf9d1362", + "zh:8b562a818915fb0d85959257095251a05c76f3467caa3ba95c583ba5fe043f9b", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9c385d03a958b54e2afd5279cd8c7cbdd2d6ca5c7d6a333e61092331f38af7cf", + "zh:b3ca45f2821a89af417787df8289cb4314b273d29555ad3b2a5ab98bb4816b3b", + "zh:da3c317f1db2469615ab40aa6baba63b5643bae7110ff855277a1fb9d8eb4f2c", + "zh:dc6430622a8dc5cdab359a8704aec81d3825ea1d305bbb3bbd032b1c6adfae0c", + "zh:fac0d2ddeadf9ec53da87922f666e1e73a603a611c57bcbc4b86ac2821619b1d", + ] +} + +provider "registry.terraform.io/hashicorp/http" { + version = "2.2.0" + constraints = ">= 2.0.0, < 3.0.0" + hashes = [ + "h1:syLdPUKrNIJ7mF7+ijSSUot8VIuFL/45kbN5UcHEIvU=", + "zh:159add5739a597c08439318f67c440a90ce8444a009e7b8aabbcb9279da9191f", + "zh:1e5fbe9a4b8d3d9f167effc03bd5324ad6ef721c23a174e98c7eb2e8b85e34e8", + "zh:4b150790ac5948ceec4f97df4deaff835e4798049d858c20413cbdff6e610c4d", + "zh:4f85c6130249f45ff0dccdcfe78296382c930c288e2f8ec844d73fa48ab3c4ef", + "zh:74a1270db30043d9601ed70fecea568693552758f912a37b423dec1530a6f390", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:8125231d0b16283130bac7cb4b0c4972a596af10e1a7348ff905dcb02ea61dc9", + "zh:83f78277025d276ee6f4fd1ae93cf69b748c6601e7cfc7e30f07beca9ce4dfdd", + "zh:abca2c2e14ce0984a1353f03fd13e9dc19312ab7844f64129ec09628e3d5d472", + "zh:b5d0f58057c730aab9a0bf348a9143e8a0ae18f2a3ddfb9f56c603aa62212601", + "zh:bc6054404263c1d7faaffe4c27d1f93dd5a9848d515a6eae42d43828c3e10447", + "zh:cb9d4a0aeebd25cbbae5b7c726deb285c007079191bc43a6a8d6b951b7ef928a", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.5.1" + constraints = "~> 3.1" + hashes = [ + "h1:VSnd9ZIPyfKHOObuQCaKfnjIHRtR7qTw19Rz8tJxm+k=", + "zh:04e3fbd610cb52c1017d282531364b9c53ef72b6bc533acb2a90671957324a64", + "zh:119197103301ebaf7efb91df8f0b6e0dd31e6ff943d231af35ee1831c599188d", + "zh:4d2b219d09abf3b1bb4df93d399ed156cadd61f44ad3baf5cf2954df2fba0831", + "zh:6130bdde527587bbe2dcaa7150363e96dbc5250ea20154176d82bc69df5d4ce3", + "zh:6cc326cd4000f724d3086ee05587e7710f032f94fc9af35e96a386a1c6f2214f", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:b6d88e1d28cf2dfa24e9fdcc3efc77adcdc1c3c3b5c7ce503a423efbdd6de57b", + "zh:ba74c592622ecbcef9dc2a4d81ed321c4e44cddf7da799faa324da9bf52a22b2", + "zh:c7c5cde98fe4ef1143bd1b3ec5dc04baf0d4cc3ca2c5c7d40d17c0e9b2076865", + "zh:dac4bad52c940cd0dfc27893507c1e92393846b024c5a9db159a93c534a3da03", + "zh:de8febe2a2acd9ac454b844a4106ed295ae9520ef54dc8ed2faf29f12716b602", + "zh:eab0d0495e7e711cca367f7d4df6e322e6c562fc52151ec931176115b83ed014", + ] +} diff --git a/test/test.tf b/test/test.tf new file mode 100644 index 0000000..d7f59c5 --- /dev/null +++ b/test/test.tf @@ -0,0 +1,61 @@ +module "minimal" { + source = "../" + + app_name = "app" + app_env = "test" + domain_name = "example.com" + container_def_json = "{}" + subdomain = "app" + default_cert_domain_name = "*.example.com" +} + +module "full" { + source = "../" + + app_env = "app" + app_name = "test" + domain_name = "example.com" + container_def_json = "{}" + create_cd_user = true + create_dns_record = false + database_name = "app_db" + database_user = "root_user" + desired_count = 2 + subdomain = "app" + create_dashboard = false + asg_min_size = "2" + asg_max_size = "3" + alarm_actions_enabled = true + ssh_key_name = "ssh" + aws_zones = ["us-west-2a"] + default_cert_domain_name = "*.example.com" + instance_type = "t3.micro" + create_adminer = true + enable_adminer = true +} + +provider "aws" { + region = local.aws_region +} + +locals { + aws_region = "us-east1" +} + +terraform { + required_version = ">= 1.0" + required_providers { + aws = { + version = "~> 4.0" + source = "hashicorp/aws" + } + cloudflare = { + version = "~> 3.0" + source = "cloudflare/cloudflare" + } + random = { + version = "~> 3.0" + source = "hashicorp/random" + } + } +} diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..e1bec44 --- /dev/null +++ b/variables.tf @@ -0,0 +1,158 @@ + +/* + * App configuration + */ + +variable "app_env" { + description = "The abbreviated version of the environment used for naming resources, typically either stg or prod" + type = string +} + +variable "app_name" { + description = "A name to be used, combined with \"app_env\", for naming resources. Should be unique in the AWS account." + type = string +} + + +/* + * IAM configuration + */ + +variable "create_cd_user" { + description = "Set to true to create an IAM user with permissions for continuous deployment" + default = false + type = bool +} + + +/* + * Cloudwatch configuration + */ + +variable "create_dashboard" { + description = "Set to false to skip creation of a CloudWatch dashboard" + default = true + type = bool +} + + +/* + * DNS configuration + */ + +variable "create_dns_record" { + description = "Set to false to skip creation of a Cloudflare DNS record" + default = true + type = bool +} + +variable "domain_name" { + description = "The domain name on which to host the app. Combined with \"subdomain\" to create an ALB listener rule. Also used for the optional DNS record." + type = string +} + +variable "subdomain" { + description = "The subdomain on which to host the app. Combined with \"domain_name\" to create an ALB listener rule. Also used for the optional DNS record." + type = string +} + + +/* + * ECS configuration + */ + +variable "container_def_json" { + description = "The ECS container task definition json" + type = string +} + +variable "desired_count" { + description = "The ECS service \"desired_count\" value" + default = 2 + type = number +} + + +/* + * ASG configuration + */ + +variable "alarm_actions_enabled" { + description = "Set to true to enable auto-scaling events and actions" + default = false + type = bool +} + +variable "asg_min_size" { + description = "The minimum size of the Autoscaling Group" + default = 1 + type = number +} + +variable "asg_max_size" { + description = "The maximum size of the Autoscaling Group" + default = 5 + type = number +} + +variable "instance_type" { + description = "See: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-types.html#AvailableInstanceTypes" + default = "t2.micro" + type = string +} + +variable "ssh_key_name" { + description = "Name of SSH key pair to use as default (ec2-user) user key. Set in the launch template" + default = "" + type = string +} + + +/* + * VPC configuration + */ + +variable "aws_zones" { + description = "The VPC availability zone list" + default = ["us-east-1c", "us-east-1d", "us-east-1e"] + type = list(string) +} + + +/* + * ALB configuration + */ + +variable "default_cert_domain_name" { + description = "Default/primary certificate domain name. Used to reference an existing certificate for use in the ALB" + type = string +} + + +/* + * Database configuration + */ + +variable "database_name" { + description = "The name assigned to the created database" + default = "db" + type = string +} + +variable "database_user" { + description = "The name of the database root user" + default = "root" + type = string +} + +variable "create_adminer" { + description = "Set to true to create an Adminer database manager app instance" + default = false + type = bool +} + +variable "enable_adminer" { + description = "Set to true to create a DNS record and start the Adminer app. Requires create_adminer = true." + default = false + type = bool +} diff --git a/versions.tf b/versions.tf new file mode 100644 index 0000000..a7e4e14 --- /dev/null +++ b/versions.tf @@ -0,0 +1,18 @@ + +terraform { + required_version = ">= 1.0" + required_providers { + aws = { + version = "~> 4.0" + source = "hashicorp/aws" + } + cloudflare = { + version = "~> 3.0" + source = "cloudflare/cloudflare" + } + random = { + version = "~> 3.0" + source = "hashicorp/random" + } + } +} diff --git a/vpc.tf b/vpc.tf new file mode 100644 index 0000000..0a7582e --- /dev/null +++ b/vpc.tf @@ -0,0 +1,120 @@ + +/* + * Create VPC + */ +module "vpc" { + source = "github.com/silinternational/terraform-modules//aws/vpc?ref=8.2.1" + app_name = var.app_name + app_env = var.app_env + aws_zones = var.aws_zones +} + +/* + * Security group to limit traffic to Cloudflare IPs + */ +module "cloudflare-sg" { + source = "github.com/silinternational/terraform-modules//aws/cloudflare-sg?ref=8.2.1" + vpc_id = module.vpc.id +} + +/* + * Create CloudFlow Logs to CloudWatch + */ +resource "aws_flow_log" "vpc_flow_log" { + iam_role_arn = aws_iam_role.vpc_flow_log.arn + log_destination = aws_cloudwatch_log_group.vpc_flow_log.arn + traffic_type = "ALL" + vpc_id = module.vpc.id +} + +resource "aws_cloudwatch_log_group" "vpc_flow_log" { + name = "${local.app_name_and_env}-vpc-flow-log" + retention_in_days = "30" +} + +resource "aws_iam_role" "vpc_flow_log" { + name = "VPCFlowLog-${local.app_name_and_env}" + + assume_role_policy = <