diff --git a/.gitignore b/.gitignore index f3fe0ca18..f177148ef 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ gradle.properties .idea/ .vscode/ + +.terraform/ + +.DS_Store diff --git a/iac/.terraform.lock.hcl b/iac/.terraform.lock.hcl new file mode 100644 index 000000000..94d82bc64 --- /dev/null +++ b/iac/.terraform.lock.hcl @@ -0,0 +1,85 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.31.0" + constraints = "~> 5.0" + hashes = [ + "h1:ltxyuBWIy9cq0kIKDJH1jeWJy/y7XJLjS4QrsQK4plA=", + "zh:0cdb9c2083bf0902442384f7309367791e4640581652dda456f2d6d7abf0de8d", + "zh:2fe4884cb9642f48a5889f8dff8f5f511418a18537a9dfa77ada3bcdad391e4e", + "zh:36d8bdd72fe61d816d0049c179f495bc6f1e54d8d7b07c45b62e5e1696882a89", + "zh:539dd156e3ec608818eb21191697b230117437a58587cbd02ce533202a4dd520", + "zh:6a53f4b57ac4eb3479fc0d8b6e301ca3a27efae4c55d9f8bd24071b12a03361c", + "zh:6faeb8ff6792ca7af1c025255755ad764667a300291cc10cea0c615479488c87", + "zh:7d9423149b323f6d0df5b90c4d9029e5455c670aea2a7eb6fef4684ba7eb2e0b", + "zh:8235badd8a5d0993421cacf5ead48fac73d3b5a25c8a68599706a404b1f70730", + "zh:860b4f60842b2879c5128b7e386c8b49adeda9287fed12c5cd74861bb659bbcd", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b021fceaf9382c8fe3c6eb608c24d01dce3d11ba7e65bb443d51ca9b90e9b237", + "zh:b38b0bfc1c69e714e80cf1c9ea06e687ee86aa9f45694be28eb07adcebbe0489", + "zh:c972d155f6c01af9690a72adfb99cfc24ef5ef311ca92ce46b9b13c5c153f572", + "zh:e0dd29920ec84fdb6026acff44dcc1fb1a24a0caa093fa04cdbc713d384c651d", + "zh:e3127ebd2cb0374cd1808f911e6bffe2f4ac4d84317061381242353f3a7bc27d", + ] +} + +provider "registry.terraform.io/hashicorp/local" { + version = "2.5.1" + constraints = ">= 1.2.0" + hashes = [ + "h1:/GAVA/xheGQcbOZEq0qxANOg+KVLCA7Wv8qluxhTjhU=", + "zh:0af29ce2b7b5712319bf6424cb58d13b852bf9a777011a545fac99c7fdcdf561", + "zh:126063ea0d79dad1f68fa4e4d556793c0108ce278034f101d1dbbb2463924561", + "zh:196bfb49086f22fd4db46033e01655b0e5e036a5582d250412cc690fa7995de5", + "zh:37c92ec084d059d37d6cffdb683ccf68e3a5f8d2eb69dd73c8e43ad003ef8d24", + "zh:4269f01a98513651ad66763c16b268f4c2da76cc892ccfd54b401fff6cc11667", + "zh:51904350b9c728f963eef0c28f1d43e73d010333133eb7f30999a8fb6a0cc3d8", + "zh:73a66611359b83d0c3fcba2984610273f7954002febb8a57242bbb86d967b635", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7ae387993a92bcc379063229b3cce8af7eaf082dd9306598fcd42352994d2de0", + "zh:9e0f365f807b088646db6e4a8d4b188129d9ebdbcf2568c8ab33bddd1b82c867", + "zh:b5263acbd8ae51c9cbffa79743fbcadcb7908057c87eb22fd9048268056efbc4", + "zh:dfcd88ac5f13c0d04e24be00b686d069b4879cc4add1b7b1a8ae545783d97520", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.6.1" + constraints = ">= 2.2.0" + hashes = [ + "h1:a+Goawwh6Qtg4/bRWzfDtIdrEFfPlnVy0y4LdUQY3nI=", + "zh:2a0ec154e39911f19c8214acd6241e469157489fc56b6c739f45fbed5896a176", + "zh:57f4e553224a5e849c99131f5e5294be3a7adcabe2d867d8a4fef8d0976e0e52", + "zh:58f09948c608e601bd9d0a9e47dcb78e2b2c13b4bda4d8f097d09152ea9e91c5", + "zh:5c2a297146ed6fb3fe934c800e78380f700f49ff24dbb5fb5463134948e3a65f", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7ce41e26f0603e31cdac849085fc99e5cd5b3b73414c6c6d955c0ceb249b593f", + "zh:8c9e8d30c4ef08ee8bcc4294dbf3c2115cd7d9049c6ba21422bd3471d92faf8a", + "zh:93e91be717a7ffbd6410120eb925ebb8658cc8f563de35a8b53804d33c51c8b0", + "zh:982542e921970d727ce10ed64795bf36c4dec77a5db0741d4665230d12250a0d", + "zh:b9d1873f14d6033e216510ef541c891f44d249464f13cc07d3f782d09c7d18de", + "zh:cfe27faa0bc9556391c8803ade135a5856c34a3fe85b9ae3bdd515013c0c87c1", + "zh:e4aabf3184bbb556b89e4b195eab1514c86a2914dd01c23ad9813ec17e863a8a", + ] +} + +provider "registry.terraform.io/hashicorp/time" { + version = "0.11.1" + constraints = ">= 0.7.0" + hashes = [ + "h1:pQGSL9mdgw4qsLndFYsEF93mbsIxyxNoAyIbBqhS3Xo=", + "zh:19a393db736ec4fd024d098d55aefaef07056c37a448ece3b55b3f5f4c2c7e4a", + "zh:227fa1e221de2907f37be78d40c06ca6a6f7b243a1ec33ade014dfaf6d92cd9c", + "zh:29970fecbf4a3ca23bacbb05d6b90cdd33dd379f90059fe39e08289951502d9f", + "zh:65024596f22f10e7dcb5e0e4a75277f275b529daa0bc0daf34ca7901c678ab88", + "zh:694d080cb5e3bf5ef08c7409208d061c135a4f5f4cdc93ea8607860995264b2e", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:b29d15d13e1b3412e6a4e1627d378dbd102659132f7488f64017dd6b6d5216d3", + "zh:bb79f4cae9f8c17c73998edc54aa16c2130a03227f7f4e71fc6ac87e230575ec", + "zh:ceccf80e95929d97f62dcf1bb3c7c7553d5757b2d9e7d222518722fc934f7ad5", + "zh:f40e638336527490e294d9c938ae55919069e6987e85a80506784ba90348792a", + "zh:f99ef33b1629a3b2278201142a3011a8489e66d92da832a5b99e442204de18fb", + "zh:fded14754ea46fdecc62a52cd970126420d4cd190e598cb61190b4724a727edb", + ] +} diff --git a/iac/README.md b/iac/README.md new file mode 100644 index 000000000..8a5fc16f9 --- /dev/null +++ b/iac/README.md @@ -0,0 +1,17 @@ +# IAC for TNoodle + +## Requirements + +- Terraform +- AWS Account with credentials configured + +## Get started + +```bash +cd iac +terraform init +terraform apply -target='module.tnoodle_frontend.aws_s3_bucket.fontend_bucket' +terraform apply +``` + +If you agree with the plan in the output, type `yes`. diff --git a/iac/alb.tf b/iac/alb.tf new file mode 100644 index 000000000..87faf8b84 --- /dev/null +++ b/iac/alb.tf @@ -0,0 +1,50 @@ +resource "aws_alb" "tnoodle_load_balancer" { + name = "tnoodle-alb" + security_groups = [aws_security_group.http_security_group.id] + subnets = [aws_default_subnet.default_az1.id, aws_default_subnet.default_az2.id] + idle_timeout = 300 + + tags = { + (var.type) = var.type_alb + } +} + +resource "aws_alb_listener" "api_lb_listener" { + load_balancer_arn = aws_alb.tnoodle_load_balancer.arn + port = var.https_port + protocol = "HTTPS" + certificate_arn = data.aws_acm_certificate.certificate.arn + ssl_policy = "ELBSecurityPolicy-TLS-1-2-Ext-2018-06" + + default_action { + target_group_arn = aws_lb_target_group.tnoodle_tg.arn + type = "forward" + } +} + +resource "aws_lb_target_group" "tnoodle_tg" { + name_prefix = "kctg1" + port = var.tnoodle_port + protocol = "HTTP" + vpc_id = aws_default_vpc.default.id + target_type = "ip" + + lifecycle { + create_before_destroy = true + } + + health_check { + healthy_threshold = "3" + interval = "60" + protocol = "HTTP" + matcher = "200" + timeout = "3" + path = "/" + unhealthy_threshold = "2" + } + + tags = { + (var.type) = var.type_tg + } + depends_on = [aws_alb.tnoodle_load_balancer] +} diff --git a/iac/certificate.tf b/iac/certificate.tf new file mode 100644 index 000000000..c22c10f70 --- /dev/null +++ b/iac/certificate.tf @@ -0,0 +1,17 @@ +data "aws_acm_certificate" "certificate" { + domain = "*.${var.domain_name}" + statuses = ["ISSUED"] +} + +provider "aws" { + region = "us-east-1" + alias = "us_east_1" +} + +data "aws_acm_certificate" "certificate_us_east_1" { + domain = "*.${var.domain_name}" + statuses = ["ISSUED"] + + provider = aws.us_east_1 +} + diff --git a/iac/data-ssm.tf b/iac/data-ssm.tf new file mode 100644 index 000000000..9b27e5886 --- /dev/null +++ b/iac/data-ssm.tf @@ -0,0 +1,3 @@ +data "aws_ssm_parameter" "wca_zone_id" { + name = "/route53/wca-zone-id" +} diff --git a/iac/ecr.tf b/iac/ecr.tf new file mode 100644 index 000000000..48b336f50 --- /dev/null +++ b/iac/ecr.tf @@ -0,0 +1,17 @@ +resource "aws_ecr_repository" "tnoodle" { + name = "tnoodle" + + image_scanning_configuration { + scan_on_push = true + } + + tags = { + (var.type) = var.type_ecr + } +} + +resource "aws_ecr_lifecycle_policy" "expire_policy" { + repository = aws_ecr_repository.tnoodle.name + + policy = templatefile("./templates/ecr/expire-policy.json", {}) +} diff --git a/iac/ecs.tf b/iac/ecs.tf new file mode 100644 index 000000000..65da38129 --- /dev/null +++ b/iac/ecs.tf @@ -0,0 +1,54 @@ +resource "aws_ecs_cluster" "tnoodle_cluster" { + name = "tnoodle-cluster" + + tags = { + (var.type) = var.type_ecs + } +} + +resource "aws_ecs_task_definition" "tnoodle_task_definition" { + family = "tnoodle-task-definition" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.fargate_cpu + memory = var.fargate_memory + execution_role_arn = aws_iam_role.ecs_task_execution_role.arn + task_role_arn = aws_iam_role.ecs_task_execution_role.arn + + container_definitions = templatefile("./templates/container-definitions/tnoodle.json.tpl", { + app_image = aws_ecr_repository.tnoodle.repository_url + aws_region = var.aws_region + app_port = var.tnoodle_port + container_name = var.tnoodle_name + fargate_cpu = var.fargate_cpu + fargate_memory = var.fargate_memory + }) + tags = { + (var.type) = var.type_ecs + } +} + +resource "aws_ecs_service" "tnoodle_service" { + name = "tnoodle-service" + cluster = aws_ecs_cluster.tnoodle_cluster.id + desired_count = 1 + launch_type = "FARGATE" + + task_definition = aws_ecs_task_definition.tnoodle_task_definition.arn + + network_configuration { + subnets = [aws_default_subnet.default_az1.id] + security_groups = [aws_security_group.allow_tnoodle_default_port.id] + assign_public_ip = true + } + + load_balancer { + target_group_arn = aws_lb_target_group.tnoodle_tg.arn + container_name = var.tnoodle_name + container_port = var.tnoodle_port + } + + tags = { + (var.type) = var.type_ecs + } +} diff --git a/iac/frontend.tf b/iac/frontend.tf new file mode 100644 index 000000000..313585cad --- /dev/null +++ b/iac/frontend.tf @@ -0,0 +1,10 @@ +module "tnoodle_frontend" { + source = "./modules/frontend" + + aws_region = var.aws_region + project_name = var.tnoodle_name + zone_id = data.aws_ssm_parameter.wca_zone_id.value + domain_name = var.domain_name + org_name = var.org_name + certificate_arn = data.aws_acm_certificate.certificate_us_east_1.arn +} diff --git a/iac/main.tf b/iac/main.tf new file mode 100644 index 000000000..1ccdd8dbb --- /dev/null +++ b/iac/main.tf @@ -0,0 +1,20 @@ +terraform { + backend "s3" { + bucket = "NON-EXISTING-BUCKET" + key = "tnoodle-web-scramble" + region = "us-west-2" + } +} + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = "us-west-2" +} diff --git a/iac/modules/frontend/README.md b/iac/modules/frontend/README.md new file mode 100644 index 000000000..d555d7ef9 --- /dev/null +++ b/iac/modules/frontend/README.md @@ -0,0 +1,3 @@ +# Frontend Module + +This holds the infra to deploy TNoodle's frontend. diff --git a/iac/modules/frontend/cloudfront.tf b/iac/modules/frontend/cloudfront.tf new file mode 100644 index 000000000..ae70b30dd --- /dev/null +++ b/iac/modules/frontend/cloudfront.tf @@ -0,0 +1,37 @@ +module "cdn" { + source = "cloudposse/cloudfront-s3-cdn/aws" + + # Cloud Posse recommends pinning every module to a specific version + version = "0.92.0" + + origin_bucket = aws_s3_bucket.fontend_bucket.id + s3_access_logging_enabled = false + logging_enabled = false + cached_methods = ["HEAD", "GET", "OPTIONS"] + default_ttl = "86400" + name = "cdn" + stage = terraform.workspace + namespace = var.domain_name + error_document = "index.html" + aliases = ["${var.project_name}.${var.domain_name}"] + dns_alias_enabled = false + acm_certificate_arn = var.certificate_arn + minimum_protocol_version = "TLSv1.2_2021" + + custom_error_response = [ + { + error_caching_min_ttl = 10, + error_code = 403 + response_code = 403 + response_page_path = "/index.html" + }, + { + error_caching_min_ttl = 10, + error_code = 404 + response_code = 404 + response_page_path = "/index.html" + } + ] + + depends_on = [aws_s3_bucket.fontend_bucket] +} diff --git a/iac/modules/frontend/main.tf b/iac/modules/frontend/main.tf new file mode 100644 index 000000000..e69de29bb diff --git a/iac/modules/frontend/outputs.tf b/iac/modules/frontend/outputs.tf new file mode 100644 index 000000000..c9b773951 --- /dev/null +++ b/iac/modules/frontend/outputs.tf @@ -0,0 +1,9 @@ +output "deployment_bucket" { + value = aws_s3_bucket.fontend_bucket.bucket +} + +output "cf_distribution_id" { + value = module.cdn.cf_id +} + + diff --git a/iac/modules/frontend/route53.tf b/iac/modules/frontend/route53.tf new file mode 100644 index 000000000..7bbf1f892 --- /dev/null +++ b/iac/modules/frontend/route53.tf @@ -0,0 +1,11 @@ +resource "aws_route53_record" "project_record" { + zone_id = var.zone_id + name = var.project_name + type = "A" + + alias { + name = module.cdn.cf_domain_name + evaluate_target_health = true + zone_id = module.cdn.cf_hosted_zone_id + } +} diff --git a/iac/modules/frontend/s3.tf b/iac/modules/frontend/s3.tf new file mode 100644 index 000000000..125a24a53 --- /dev/null +++ b/iac/modules/frontend/s3.tf @@ -0,0 +1,3 @@ +resource "aws_s3_bucket" "fontend_bucket" { + bucket = "${var.org_name}-${var.project_name}-frontend" +} diff --git a/iac/modules/frontend/variables.tf b/iac/modules/frontend/variables.tf new file mode 100644 index 000000000..efccc2e8b --- /dev/null +++ b/iac/modules/frontend/variables.tf @@ -0,0 +1,23 @@ +variable "aws_region" { + description = "AWS Region" +} + +variable "project_name" { + description = "The name of this project" +} + +variable "zone_id" { + description = "The Route53 zone ID" +} + +variable "domain_name" { + description = "The domain name, web URL" +} + +variable "org_name" { + description = "The name of the organization" +} + +variable "certificate_arn" { + description = "The ARN of the ACM certificate" +} diff --git a/iac/outputs.tf b/iac/outputs.tf new file mode 100644 index 000000000..26f104136 --- /dev/null +++ b/iac/outputs.tf @@ -0,0 +1,7 @@ +output "tnoodle_deployment_bucket" { + value = module.tnoodle_frontend.deployment_bucket +} + +output "tnoodle_distribution_id" { + value = module.tnoodle_frontend.cf_distribution_id +} diff --git a/iac/role.tf b/iac/role.tf new file mode 100644 index 000000000..8b44d9ded --- /dev/null +++ b/iac/role.tf @@ -0,0 +1,38 @@ +data "aws_iam_policy_document" "ecs_task_execution_role" { + version = "2012-10-17" + statement { + sid = "" + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "ecs_task_execution_role" { + name = "tnoodle-task-execution-role" + assume_role_policy = data.aws_iam_policy_document.ecs_task_execution_role.json + + inline_policy { + name = "tnoodle-task-execution-role-policy" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = ["logs:*"] + Effect = "Allow" + Resource = "*" + }, + { + Action = ["ecr:*"] + Effect = "Allow" + Resource = "*" + } + ] + }) + } +} diff --git a/iac/route53.tf b/iac/route53.tf new file mode 100644 index 000000000..18d551343 --- /dev/null +++ b/iac/route53.tf @@ -0,0 +1,25 @@ +# tnoodle-api.worldcubeassociation.org +resource "aws_route53_record" "project_record" { + zone_id = data.aws_ssm_parameter.wca_zone_id.value + name = "${var.tnoodle_name}-api" + type = "A" + + alias { + name = "dualstack.${aws_alb.tnoodle_load_balancer.dns_name}" + evaluate_target_health = true + zone_id = aws_alb.tnoodle_load_balancer.zone_id + } +} + +# scramble-api.worldcubeassociation.org +resource "aws_route53_record" "alternative_record" { + zone_id = data.aws_ssm_parameter.wca_zone_id.value + name = "scramble-api" + type = "A" + + alias { + name = "dualstack.${aws_alb.tnoodle_load_balancer.dns_name}" + evaluate_target_health = true + zone_id = aws_alb.tnoodle_load_balancer.zone_id + } +} diff --git a/iac/security-group.tf b/iac/security-group.tf new file mode 100644 index 000000000..89b62f898 --- /dev/null +++ b/iac/security-group.tf @@ -0,0 +1,59 @@ +resource "aws_security_group" "http_security_group" { + name = "http-security-group-tnoodle" + description = "Allow HTTP" + vpc_id = aws_default_vpc.default.id + + ingress { + description = "HTTP" + from_port = var.http_port + to_port = var.http_port + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "HTTPS" + from_port = var.https_port + to_port = var.https_port + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } + + tags = { + (var.type) = var.type_sg + } +} + +resource "aws_security_group" "allow_tnoodle_default_port" { + name = "tnoodle-default-port" + description = "Allow connection to tnoodle default port" + vpc_id = aws_default_vpc.default.id + + ingress { + description = "tnoodle" + from_port = var.tnoodle_port + to_port = var.tnoodle_port + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } + + tags = { + (var.type) = var.type_sg + } +} diff --git a/iac/subnet.tf b/iac/subnet.tf new file mode 100644 index 000000000..cf898e670 --- /dev/null +++ b/iac/subnet.tf @@ -0,0 +1,15 @@ +resource "aws_default_subnet" "default_az1" { + availability_zone = "us-west-2a" + + lifecycle { + ignore_changes = [tags] + } +} + +resource "aws_default_subnet" "default_az2" { + availability_zone = "us-west-2b" + + lifecycle { + ignore_changes = [tags] + } +} diff --git a/iac/templates/container-definitions/tnoodle.json.tpl b/iac/templates/container-definitions/tnoodle.json.tpl new file mode 100644 index 000000000..33a4aad2d --- /dev/null +++ b/iac/templates/container-definitions/tnoodle.json.tpl @@ -0,0 +1,24 @@ +[ + { + "name": "${container_name}", + "image": "${app_image}:latest", + "cpu": ${fargate_cpu}, + "memory": ${fargate_memory}, + "networkMode": "awsvpc", + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/${container_name}", + "awslogs-region": "${aws_region}", + "awslogs-stream-prefix": "ecs", + "awslogs-create-group": "true" + } + }, + "portMappings": [ + { + "containerPort": ${app_port}, + "hostPort": ${app_port} + } + ] + } +] diff --git a/iac/templates/ecr/expire-policy.json b/iac/templates/ecr/expire-policy.json new file mode 100644 index 000000000..e5b5363c6 --- /dev/null +++ b/iac/templates/ecr/expire-policy.json @@ -0,0 +1,16 @@ +{ + "rules": [ + { + "rulePriority": 1, + "description": "Keep last 2 images", + "selection": { + "tagStatus": "any", + "countType": "imageCountMoreThan", + "countNumber": 2 + }, + "action": { + "type": "expire" + } + } + ] +} diff --git a/iac/variable.tf b/iac/variable.tf new file mode 100644 index 000000000..45e71d3b8 --- /dev/null +++ b/iac/variable.tf @@ -0,0 +1,64 @@ +variable "type" { + default = "Type" +} + +variable "type_ecs" { + default = "ECS" +} + +variable "aws_region" { + default = "us-west-2" +} + +variable "tnoodle_name" { + default = "tnoodle" +} + +variable "type_ecr" { + default = "ECR" +} + +variable "tnoodle_port" { + default = "2014" +} + +variable "fargate_cpu" { + default = "1024" # 1 vCPU +} + +variable "fargate_memory" { + default = "2048" # 2 GB +} + +variable "type_subnet" { + default = "SUBNET" +} + +variable "type_sg" { + default = "SECURITY-GROUP" +} + +variable "type_tg" { + default = "TARGET-GROUP" +} + +variable "type_alb" { + default = "LOAD-BALANCER" +} + +variable "http_port" { + default = "80" +} + +variable "https_port" { + default = "443" +} + +variable "domain_name" { + default = "worldcubeassociation.org" +} + +variable "org_name" { + description = "Organization's identifier" + default = "wca" +} diff --git a/iac/vpc.tf b/iac/vpc.tf new file mode 100644 index 000000000..4d9541989 --- /dev/null +++ b/iac/vpc.tf @@ -0,0 +1,5 @@ +resource "aws_default_vpc" "default" { + lifecycle { + ignore_changes = [tags] + } +}