diff --git a/.gitignore b/.gitignore index 40550e7..9a3b7b5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ hashicorp/ *.tfstate *.tfvars *.tfstate.backup + +.DS_Store diff --git a/README.md b/README.md index c68d8c4..bce77eb 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,12 @@ Disclaimer: Please use these modules only if you're comfortable configuring Terr # Usage Navigate to your desired cloud provider + deployment module for specific configuration options. + +# How to deploy +1. Make sure you're on the `production` workspace. +2. Run `tf apply` + +# How to upgrade +1. Check the latest version of Retool on https://docs.retool.com/changelog/tags/self-hosted +2. Change the version number in the `main.tf` file of the `ecs_retool_image` on line 23. +3. Deploy diff --git a/main.tf b/main.tf index e69de29..45b3389 100644 --- a/main.tf +++ b/main.tf @@ -0,0 +1,107 @@ +data "aws_ssm_parameter" "retool_license_key" { + name = "/overwatch/${local.stage}/RETOOL_LICENSE_KEY" +} +data "aws_ssm_parameter" "magement_api_key" { + name = "/unify/${terraform.workspace == "production" ? "prod" : "staging"}/API_GATEWAY_API_KEY" +} + +module "retool" { + source = "./modules/aws_ecs_fargate" + + providers = { + aws = aws + aws.us_east_1 = aws.us_east_1 + } + + deployment_name = "overwatch" + aws_region = "eu-central-1" + vpc_id = module.platform_network.vpc_id + vpc_cidr_block = module.platform_network.vpc_cidr_block + private_subnet_ids = [for subnet in module.platform_network.main_private_subnets : subnet.id] + public_subnet_ids = [for subnet in module.platform_network.main_public_subnets : subnet.id] + # ssh_key_pair = "" + ecs_retool_image = "tryretool/backend:3.24.6" + domain_name = local.domain_name + + retool_license_key = data.aws_ssm_parameter.retool_license_key.arn + log_retention_in_days = 7 + + management_api_key = data.aws_ssm_parameter.magement_api_key.arn + + environment = local.environment + stage = local.stage + # ecs_insights_enabled = true + additional_env_vars = [{ + name = "DOMAINS", + value = "overwatch.${local.domain_name}" + }, { + name = "BASE_DOMAIN", + value = "https://overwatch.${local.domain_name}" + }, { + name = "DISABLE_INTERCOM", + value = "true" + }, { + name = "DISABLE_USER_PASS_LOGIN", + value = "true" + }, { + name = "RESTRICTED_DOMAIN", + value = "apideck.com" + }, { + name = "HIDE_PROD_AND_STAGING_TOGGLES", + value = "true" + }, { + name = "DISABLE_GIT_SYNCING" + value = "true" + }, { + name = "TRIGGER_OAUTH_2_SSO_LOGIN_AUTOMATICALLY" + value = "true" + }, { + name = "CUSTOM_OAUTH2_SSO_CLIENT_ID" + value = var.client_id + }, { + name = "CUSTOM_OAUTH2_SSO_SCOPES" + value = "openid email profile https://www.googleapis.com/auth/userinfo.profile" + }, { + name = "CUSTOM_OAUTH2_SSO_AUTH_URL" + value = "https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&prompt=consent" + }, { + name = "CUSTOM_OAUTH2_SSO_TOKEN_URL" + value = "https://oauth2.googleapis.com/token" + }, { + name = "CUSTOM_OAUTH2_SSO_JWT_EMAIL_KEY" + value = "idToken.email" + }, { + name = "CUSTOM_OAUTH2_SSO_JWT_FIRST_NAME_KEY" + value = "idToken.given_name" + }, { + name = "CUSTOM_OAUTH2_SSO_JWT_LAST_NAME_KEY" + value = "idToken.family_name" + }, { + name = "CUSTOM_OAUTH2_SSO_ACCESS_TOKEN_LIFESPAN_MINUTES" + value = "45" + }, { + name = "DATABASE_MIGRATIONS_TIMEOUT_SECONDS" + value = "900" + } + ] + + alb_ingress_rules = [{ + + description = "Global HTTPS inbound" + from_port = "443" + to_port = "443" + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + + }] +} + +module "platform_network" { + source = "./modules/platform_network" + + prefix = local.prefix + project = local.project + environment = local.environment + stage = local.stage +} diff --git a/modules/aws_ecs/loadbalancers.tf b/modules/aws_ecs/loadbalancers.tf index 1dc1bb3..fa3be11 100644 --- a/modules/aws_ecs/loadbalancers.tf +++ b/modules/aws_ecs/loadbalancers.tf @@ -2,6 +2,8 @@ resource "aws_lb" "this" { name = "${var.deployment_name}-alb" idle_timeout = var.alb_idle_timeout + drop_invalid_header_fields = true + security_groups = [aws_security_group.alb.id] subnets = var.subnet_ids } diff --git a/modules/aws_ecs_ec2/main.tf b/modules/aws_ecs_ec2/main.tf index 761a68b..8f9c2c8 100644 --- a/modules/aws_ecs_ec2/main.tf +++ b/modules/aws_ecs_ec2/main.tf @@ -22,7 +22,7 @@ resource "aws_ecs_cluster" "this" { data "aws_ami" "this" { most_recent = true # get the latest version - name_regex = "^amzn2-ami-ecs-hvm-\\d\\.\\d\\.\\d{8}-x86_64-ebs$" + name_regex = "^amzn2-ami-ecs-hvm-\\d\\.\\d\\.\\d{8}-x86_64-ebs$" filter { name = "virtualization-type" @@ -156,9 +156,8 @@ resource "aws_db_instance" "this" { publicly_accessible = var.rds_publicly_accessible vpc_security_group_ids = [aws_security_group.rds.id] performance_insights_enabled = var.rds_performance_insights_enabled - - skip_final_snapshot = true - apply_immediately = true + skip_final_snapshot = true + apply_immediately = true } resource "aws_ecs_service" "retool" { diff --git a/modules/aws_ecs_ec2/secrets.tf b/modules/aws_ecs_ec2/secrets.tf index f47ca3a..8223abf 100644 --- a/modules/aws_ecs_ec2/secrets.tf +++ b/modules/aws_ecs_ec2/secrets.tf @@ -4,8 +4,8 @@ resource "random_string" "rds_password" { } resource "aws_secretsmanager_secret" "rds_password" { - name = "${var.deployment_name}-rds-password" - description = "This is the password for the Retool RDS instance" + name = "${var.deployment_name}-rds-password" + description = "This is the password for the Retool RDS instance" recovery_window_in_days = 0 } @@ -15,8 +15,8 @@ resource "aws_secretsmanager_secret_version" "rds_password" { } resource "aws_secretsmanager_secret" "rds_username" { - name = "${var.deployment_name}-rds-username" - description = "This is the username for the Retool RDS instance" + name = "${var.deployment_name}-rds-username" + description = "This is the username for the Retool RDS instance" recovery_window_in_days = 0 } @@ -31,8 +31,8 @@ resource "random_string" "jwt_secret" { } resource "aws_secretsmanager_secret" "jwt_secret" { - name = "${var.deployment_name}-jwt-secret" - description = "This is the secret for Retool JWTs" + name = "${var.deployment_name}-jwt-secret" + description = "This is the secret for Retool JWTs" recovery_window_in_days = 0 } @@ -48,8 +48,8 @@ resource "random_string" "encryption_key" { } resource "aws_secretsmanager_secret" "encryption_key" { - name = "${var.deployment_name}-encryption-key" - description = "This is the secret for encrypting credentials" + name = "${var.deployment_name}-encryption-key" + description = "This is the secret for encrypting credentials" recovery_window_in_days = 0 } diff --git a/modules/aws_ecs_fargate/README.md b/modules/aws_ecs_fargate/README.md new file mode 100644 index 0000000..5e16f18 --- /dev/null +++ b/modules/aws_ecs_fargate/README.md @@ -0,0 +1,113 @@ +# AWS ECS + EC2 Module + +This module deploys an ECS cluster with autoscaling group of EC2 instances. + +# Usage +1. Directly use our module in your existing Terraform configuration and provide the required variables + +``` +module "retool" { + source = "git@github.com:tryretool/retool-terraform.git//modules/aws/ecs-ec2" + + aws_region = "" + vpc_id = "" + subnet_ids = [ + "", + "" + ] + ssh_key_pair = "" + ecs_retool_image = "" + + # Additional configuration + ... +} +``` + +2. Run `terraform init` to install all requirements for the module. + +3. Replace `ecs_retool_image` with your desired [Retool Version](https://docs.retool.com/docs/updating-retool-on-premise#retool-release-versions). The format should be `tryretool/backend:X.Y.Z`, where `X.Y.Z` is your desired version number. + +4. Ensure that the default security settings in `security.tf` matches your specifications. If you need to tighten down access, pass in custom ingress and egress rules into `ec2_egress_rules`, `ec2_ingress_rules`, `alb_egress_rules`, and `alb_ingress_rules`. + +5. Check through `variables.tf` for any other input variables that may be required. + +6. Run `terraform plan` to view all planned changes to your account. + +7. Run `terraform apply` to apply the changes and deploy Retool. + +8. You should now find a Load Balancer in your AWS EC2 Console associated with the deployment. The instance address should now be running Retool. + +## Common Configuration + +### Instances + +**EC2 Instance Size** +To configure the EC instance size, set the `instance_type` input variable (e.g. `t2.large`). + +**RDS Instance Class** +To configure the RDS instance class, set the `instance_class` input variable (e.g. `db.m4.large`). + +## Advanced Configuration + + +### Security Groups +To customize the ingress and egress rules on the security groups, you can override specific input variable defaults. + +- `ec2_ingress_rules` controls the inbound rules for EC2 instances in the autoscaling group +- `ec2_egress_rules` controls the outbound rules for EC2 instances in the autoscaling group +- `alb_ingress_rules` controls the inbound rules for the Load Balancer +- `alb_egress_rules` controls the outbound rules for the Load Balancer + +``` +ec2_ingress_rules = [ + { + description = "Global HTTP inbound" + from_port = "80" + to_port = "80" + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + }, + { + description = "Global HTTPS inbound" + from_port = "443" + to_port = "443" + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + }, + { + description = "SSH inbound" + from_port = "22" + to_port = "22" + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } +] + +ec2_egress_rules = [ + { + description = "Global outbound" + from_port = "0" + to_port = "0" + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } +] +``` + +### Environment Variables +To add additional [Retool environment variables](https://docs.retool.com/docs/environment-variables) to your deployment, populate the `additional_env_vars` input variable into the module. + +NOTE: The `additional_env_vars` will only work as type `map(string)`. Convert all booleans and numbers into strings, e.g. + +``` +additional_env_vars = [ + { + name = "DISABLE_GIT_SYNCING" + value = "true" + } +] +``` diff --git a/modules/aws_ecs_fargate/certificate.tf b/modules/aws_ecs_fargate/certificate.tf new file mode 100644 index 0000000..7d2ecc1 --- /dev/null +++ b/modules/aws_ecs_fargate/certificate.tf @@ -0,0 +1,28 @@ +data "aws_route53_zone" "apideck_com" { + name = var.domain_name + private_zone = false +} + +module "apideck_acm_certificate" { + source = "terraform-aws-modules/acm/aws" + version = "~> 3.0" + + domain_name = "*.${var.domain_name}" + zone_id = data.aws_route53_zone.apideck_com.zone_id + + wait_for_validation = true +} + +module "apideck_acm_certificate_east" { + source = "terraform-aws-modules/acm/aws" + version = "~> 3.0" + + providers = { + aws = aws.us_east_1 + } + + domain_name = "*.${var.domain_name}" + zone_id = data.aws_route53_zone.apideck_com.zone_id + + wait_for_validation = true +} diff --git a/modules/aws_ecs_fargate/loadbalancers.tf b/modules/aws_ecs_fargate/loadbalancers.tf new file mode 100644 index 0000000..cd2fa39 --- /dev/null +++ b/modules/aws_ecs_fargate/loadbalancers.tf @@ -0,0 +1,87 @@ +resource "aws_lb" "this" { + name = "${var.deployment_name}-alb" + idle_timeout = var.alb_idle_timeout + + security_groups = [aws_security_group.alb.id] + subnets = var.public_subnet_ids +} + +resource "aws_lb_listener" "this" { + load_balancer_arn = aws_lb.this.arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-2016-08" + certificate_arn = module.apideck_acm_certificate.acm_certificate_arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.this.arn + } +} + +resource "aws_lb_listener_rule" "this" { + listener_arn = aws_lb_listener.this.arn + priority = 1 + + action { + type = "forward" + target_group_arn = aws_lb_target_group.this.arn + } + + condition { + path_pattern { + values = ["/"] + } + } +} + +resource "aws_lb_target_group" "this" { + name = "${var.deployment_name}-target" + vpc_id = var.vpc_id + deregistration_delay = 30 + port = 3000 + target_type = "ip" + protocol = "HTTP" + + health_check { + interval = 30 + path = "/api/checkHealth" + protocol = "HTTP" + timeout = 20 + healthy_threshold = 3 + unhealthy_threshold = 2 + port = 3000 + } + + # lifecycle { + # create_before_destroy = true + # } +} + +# resource "aws_lb_target_group" "this" { +# name = "${var.deployment_name}-target" +# vpc_id = var.vpc_id +# deregistration_delay = 30 +# port = 8080 +# target_type = "ip" +# protocol = "HTTP" + +# health_check { +# interval = 10 +# path = "/api/checkHealth" +# protocol = "HTTP" +# timeout = 5 +# healthy_threshold = 3 +# unhealthy_threshold = 2 +# port = "traffic-port" +# } + +# protocol_version = "HTTP1" + +# # NOTE: TF is unable to destroy a target group while a listener is attached, +# # therefor we have to create a new one before destroying the old. This also means +# # we have to let it have a random name, and then tag it with the desired name. +# lifecycle { +# create_before_destroy = true +# } +# } diff --git a/modules/aws_ecs_fargate/locals.tf b/modules/aws_ecs_fargate/locals.tf new file mode 100644 index 0000000..3c66d7f --- /dev/null +++ b/modules/aws_ecs_fargate/locals.tf @@ -0,0 +1,47 @@ +locals { + environment_variables = concat( + var.additional_env_vars, # add additional environment variables + [ + { + name = "NODE_ENV" + value = var.node_env + }, + { + name = "FORCE_DEPLOYMENT" + value = tostring(var.force_deployment) + }, + { + name = "POSTGRES_DB" + value = "hammerhead_${var.stage}" + }, + { + name = "POSTGRES_HOST" + value = module.rds_cluster.cluster_endpoint + }, + { + name = "POSTGRES_SSL_ENABLED" + value = "true" + }, + { + name = "POSTGRES_PORT" + value = "5432" + }, + { + "name" = "POSTGRES_USER", + "value" = var.rds_username + }, + { + "name" = "POSTGRES_PASSWORD", + "value" = random_string.rds_password.result + }, + { + "name" : "JWT_SECRET", + "value" : random_string.jwt_secret.result + }, + { + "name" : "ENCRYPTION_KEY", + "value" : random_string.encryption_key.result + } + ] + ) +} diff --git a/modules/aws_ecs_fargate/main.tf b/modules/aws_ecs_fargate/main.tf new file mode 100644 index 0000000..a6bf60a --- /dev/null +++ b/modules/aws_ecs_fargate/main.tf @@ -0,0 +1,194 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 4.0" + configuration_aliases = [aws.us_east_1] + } + } +} + +resource "aws_ecs_cluster" "this" { + name = "${var.deployment_name}-ecs" + + setting { + name = "containerInsights" + value = var.ecs_insights_enabled + } +} + +resource "aws_ecs_cluster_capacity_providers" "this" { + cluster_name = aws_ecs_cluster.this.name + + capacity_providers = ["FARGATE"] +} + +resource "aws_cloudwatch_log_group" "this" { + name = "${var.deployment_name}-ecs-log-group" + retention_in_days = var.log_retention_in_days +} + +resource "aws_ecs_service" "retool" { + name = "${var.deployment_name}-main-service" + cluster = aws_ecs_cluster.this.id + task_definition = aws_ecs_task_definition.retool.arn + desired_count = var.min_instance_count - 1 + deployment_maximum_percent = var.maximum_percent + deployment_minimum_healthy_percent = var.minimum_healthy_percent + launch_type = "FARGATE" + + network_configuration { + security_groups = [aws_security_group.ec2.id] + subnets = var.private_subnet_ids + } + + load_balancer { + target_group_arn = aws_lb_target_group.this.arn + container_name = "retool" + container_port = 3000 + } +} + +resource "aws_ecs_service" "jobs_runner" { + name = "${var.deployment_name}-jobs-runner-service" + cluster = aws_ecs_cluster.this.id + desired_count = 1 + task_definition = aws_ecs_task_definition.retool_jobs_runner.arn + launch_type = "FARGATE" + + network_configuration { + security_groups = [aws_security_group.ec2.id] + subnets = var.private_subnet_ids + } +} + +resource "aws_ecs_task_definition" "retool_jobs_runner" { + family = "retool" + task_role_arn = aws_iam_role.task_role.arn + + + network_mode = "awsvpc" + cpu = "512" + memory = "1024" + requires_compatibilities = ["FARGATE"] + + execution_role_arn = aws_iam_role.ecs_task_execution_role.arn + + container_definitions = jsonencode( + [ + { + name = "retool-jobs-runner" + essential = true + image = var.ecs_retool_image + cpu = 512 + memory = 1024 + command = [ + "./docker_scripts/start_api.sh" + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.this.id + awslogs-region = var.aws_region + awslogs-stream-prefix = "SERVICE_RETOOL" + } + } + + portMappings = [ + { + containerPort = 3000 + hostPort = 3000 + protocol = "tcp" + } + ] + + environment = concat( + local.environment_variables, + [ + { + name = "SERVICE_TYPE" + value = "JOBS_RUNNER" + } + ] + ) + + secrets = [ + { + "name" : "LICENSE_KEY", + "valueFrom" : var.retool_license_key + } + ] + } + ] + ) +} +resource "aws_ecs_task_definition" "retool" { + family = "retool" + task_role_arn = aws_iam_role.task_role.arn + execution_role_arn = aws_iam_role.ecs_task_execution_role.arn + network_mode = "awsvpc" + cpu = "2048" + memory = "4096" + requires_compatibilities = ["FARGATE"] + container_definitions = jsonencode( + [ + { + name = "retool" + essential = true + image = var.ecs_retool_image + cpu = var.ecs_task_cpu + memory = var.ecs_task_memory + command = [ + "./docker_scripts/start_api.sh" + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.this.id + awslogs-region = var.aws_region + awslogs-stream-prefix = "SERVICE_RETOOL" + } + } + + portMappings = [ + { + containerPort = 3000 + hostPort = 3000 + protocol = "tcp" + } + ] + + environment = concat( + local.environment_variables, + [ + { + name = "SERVICE_TYPE" + value = "MAIN_BACKEND,DB_CONNECTOR" + }, + { + "name" = "COOKIE_INSECURE", + "value" = tostring(var.cookie_insecure) + } + ] + ) + + secrets = [ + { + "name" : "LICENSE_KEY", + "valueFrom" : var.retool_license_key + }, + { + "name" : "CUSTOM_OAUTH2_SSO_CLIENT_SECRET", + "valueFrom" : data.aws_ssm_parameter.google_oauth2_sso_client_secret.arn + }, + { + "name" : "RETOOL_EXPOSED_MANAGEMENT_API_KEY", + "valueFrom" : var.management_api_key + }, + ] + } + ] + ) +} diff --git a/modules/aws_ecs_fargate/outputs.tf b/modules/aws_ecs_fargate/outputs.tf new file mode 100644 index 0000000..d2ecd09 --- /dev/null +++ b/modules/aws_ecs_fargate/outputs.tf @@ -0,0 +1,44 @@ +output "ecs_alb_url" { + value = aws_lb.this.dns_name + description = "Retool ALB DNS url (where Retool is running)" +} + +output "ecs_alb_arn" { + value = aws_lb.this.arn + description = "Retool ALB arn" +} + +output "ecs_cluster_name" { + value = aws_ecs_cluster.this.name + description = "Name of AWS ECS Cluster" +} + +output "ecs_cluster_arn" { + value = aws_ecs_cluster.this.arn + description = "ARN of AWS ECS Cluster" +} + +output "ecs_cluster_id" { + value = aws_ecs_cluster.this.id + description = "ID of AWS ECS Cluster" +} + +# output "rds_instance_id" { +# value = aws_db_instance.this.id +# description = "ID of AWS RDS instance" +# } + +# output "rds_instance_address" { +# value = aws_db_instance.this.address +# description = "Hostname of the RDS instance" +# } + +# output "rds_instance_arn" { +# value = aws_db_instance.this.arn +# description = "ARN of RDS instance" +# } + +# output "rds_instance_name" { +# value = aws_db_instance.this.name +# description = "Name of RDS instance" +# } diff --git a/modules/aws_ecs_fargate/rds.tf b/modules/aws_ecs_fargate/rds.tf new file mode 100644 index 0000000..727c630 --- /dev/null +++ b/modules/aws_ecs_fargate/rds.tf @@ -0,0 +1,66 @@ +data "aws_rds_engine_version" "postgresql" { + engine = "aurora-postgresql" + version = "14.6" +} + +resource "aws_db_parameter_group" "postgresql14" { + name = "${var.deployment_name}-aurora-db-postgres14-parameter-group" + family = "aurora-postgresql14" + description = "${var.deployment_name}-aurora-db-postgres14-parameter-group" + + parameter { + name = "log_statement" + value = "ddl" + } +} + +resource "aws_rds_cluster_parameter_group" "postgresql14" { + name = "${var.deployment_name}-aurora-postgres14-cluster-parameter-group" + family = "aurora-postgresql14" + description = "${var.deployment_name}-aurora-postgres14-cluster-parameter-group" +} + +module "rds_cluster" { + source = "terraform-aws-modules/rds-aurora/aws" + version = "~> 7.0" + + name = var.deployment_name + database_name = "hammerhead_${var.stage}" + engine = data.aws_rds_engine_version.postgresql.engine + engine_mode = "provisioned" + engine_version = data.aws_rds_engine_version.postgresql.version + storage_encrypted = true + + db_subnet_group_name = "apideck-${var.stage}" + create_db_subnet_group = false + + vpc_id = var.vpc_id + subnets = var.private_subnet_ids + create_security_group = false + vpc_security_group_ids = [aws_security_group.rds.id] + + monitoring_interval = 60 + + apply_immediately = true + skip_final_snapshot = true + + db_parameter_group_name = aws_db_parameter_group.postgresql14.id + db_cluster_parameter_group_name = aws_rds_cluster_parameter_group.postgresql14.id + + serverlessv2_scaling_configuration = { + min_capacity = 0.5 + max_capacity = 5 + } + + instance_class = "db.serverless" + instances = { + one = {} + } + + create_random_password = false + master_username = aws_secretsmanager_secret_version.rds_username.secret_string + master_password = aws_secretsmanager_secret_version.rds_password.secret_string + + backup_retention_period = 35 + preferred_backup_window = "03:00-05:00" +} diff --git a/modules/aws_ecs_fargate/roles.tf b/modules/aws_ecs_fargate/roles.tf new file mode 100644 index 0000000..72d5e36 --- /dev/null +++ b/modules/aws_ecs_fargate/roles.tf @@ -0,0 +1,96 @@ +data "aws_caller_identity" "current" {} + +data "aws_iam_policy" "ecs_task_execution_role_policy" { + name = "AmazonECSTaskExecutionRolePolicy" +} + +# ECS Task roles +data "aws_iam_policy_document" "assume_role_ecs_task" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "ssm_paramstore_access" { + statement { + actions = [ + "ssm:GetParameters" + ] + resources = [ + "arn:aws:ssm:eu-central-1:${data.aws_caller_identity.current.account_id}:parameter/*" + ] + } +} + +resource "aws_iam_policy" "ssm_paramstore_access" { + name = "${var.deployment_name}-ecs-to-ssm-${var.environment}" + description = "ecs task access to SSM" + policy = data.aws_iam_policy_document.ssm_paramstore_access.json +} + +resource "aws_iam_role" "ecs_task_execution_role" { + name = "${var.deployment_name}-ecs-task-execution-role" + assume_role_policy = data.aws_iam_policy_document.assume_role_ecs_task.json + managed_policy_arns = [ + aws_iam_policy.ssm_paramstore_access.arn, + data.aws_iam_policy.ecs_task_execution_role_policy.arn + ] +} + +data "aws_iam_policy_document" "task_role_assume_policy" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "task_role" { + name = "${var.deployment_name}-task-role" + assume_role_policy = data.aws_iam_policy_document.task_role_assume_policy.json + path = "/" +} + +data "aws_iam_policy_document" "service_role_assume_policy" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ecs.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "service_role_policy" { + statement { + actions = [ + "elasticloadbalancing:DeregisterInstancesFromLoadBalancer", + "elasticloadbalancing:DeregisterTargets", + "elasticloadbalancing:Describe*", + "elasticloadbalancing:RegisterInstancesWithLoadBalancer", + "elasticloadbalancing:RegisterTargets", + "ec2:Describe*", + "ec2:AuthorizeSecurityGroupIngress" + ] + resources = ["*"] + } +} + +resource "aws_iam_role" "service_role" { + name = "${var.deployment_name}-service-role" + assume_role_policy = data.aws_iam_policy_document.service_role_assume_policy.json + path = "/" + + inline_policy { + name = "${var.deployment_name}-service-policy" + policy = data.aws_iam_policy_document.service_role_policy.json + } +} diff --git a/modules/aws_ecs_fargate/route53.tf b/modules/aws_ecs_fargate/route53.tf new file mode 100644 index 0000000..1d6e488 --- /dev/null +++ b/modules/aws_ecs_fargate/route53.tf @@ -0,0 +1,11 @@ +resource "aws_route53_record" "apideck_com" { + name = "overwatch.${var.domain_name}" + type = "A" + zone_id = data.aws_route53_zone.apideck_com.id + + alias { + evaluate_target_health = true + name = aws_lb.this.dns_name + zone_id = aws_lb.this.zone_id + } +} diff --git a/modules/aws_ecs_fargate/secrets.tf b/modules/aws_ecs_fargate/secrets.tf new file mode 100644 index 0000000..06a856c --- /dev/null +++ b/modules/aws_ecs_fargate/secrets.tf @@ -0,0 +1,63 @@ +resource "random_string" "rds_password" { + length = var.secret_length + special = false +} + +resource "aws_secretsmanager_secret" "rds_password" { + name = "${var.deployment_name}-rds-password" + description = "This is the password for the Retool RDS instance" + recovery_window_in_days = 0 +} + +resource "aws_secretsmanager_secret_version" "rds_password" { + secret_id = aws_secretsmanager_secret.rds_password.id + secret_string = random_string.rds_password.result +} + +resource "aws_secretsmanager_secret" "rds_username" { + name = "${var.deployment_name}-rds-username" + description = "This is the username for the Retool RDS instance" + recovery_window_in_days = 0 +} + +resource "aws_secretsmanager_secret_version" "rds_username" { + secret_id = aws_secretsmanager_secret.rds_username.id + secret_string = var.rds_username +} + +resource "random_string" "jwt_secret" { + length = var.secret_length + special = false +} + +resource "aws_secretsmanager_secret" "jwt_secret" { + name = "${var.deployment_name}-jwt-secret" + description = "This is the secret for Retool JWTs" + recovery_window_in_days = 0 +} + +resource "aws_secretsmanager_secret_version" "jwt_secret" { + secret_id = aws_secretsmanager_secret.jwt_secret.id + secret_string = random_string.jwt_secret.result +} + + +resource "random_string" "encryption_key" { + length = var.secret_length + special = false +} + +resource "aws_secretsmanager_secret" "encryption_key" { + name = "${var.deployment_name}-encryption-key" + description = "This is the secret for encrypting credentials" + recovery_window_in_days = 0 +} + +resource "aws_secretsmanager_secret_version" "encryption_key" { + secret_id = aws_secretsmanager_secret.encryption_key.id + secret_string = random_string.encryption_key.result +} + +data "aws_ssm_parameter" "google_oauth2_sso_client_secret" { + name = "/${var.deployment_name}/${var.stage}/GOOGLE_OAUTH2_SSO_CLIENT_SECRET" +} diff --git a/modules/aws_ecs_fargate/security.tf b/modules/aws_ecs_fargate/security.tf new file mode 100644 index 0000000..227147a --- /dev/null +++ b/modules/aws_ecs_fargate/security.tf @@ -0,0 +1,86 @@ +resource "aws_security_group" "rds" { + name = "${var.deployment_name}-rds-security-group" + description = "Retool database security group" + vpc_id = var.vpc_id + + ingress { + description = "Retool ECS Postgres Inbound" + from_port = "5432" + to_port = "5432" + protocol = "tcp" + cidr_blocks = [var.vpc_cidr_block] + } + + # Allow all outbound - modify if necessary + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = [ + "0.0.0.0/0" + ] + ipv6_cidr_blocks = ["::/0"] + } +} + +resource "aws_security_group" "alb" { + name = "${var.deployment_name}-alb-security-group" + description = "Retool load balancer security group" + vpc_id = var.vpc_id + + dynamic "ingress" { + for_each = var.alb_ingress_rules + content { + description = ingress.value["description"] + from_port = ingress.value["from_port"] + to_port = ingress.value["to_port"] + protocol = ingress.value["protocol"] + cidr_blocks = ingress.value["cidr_blocks"] + ipv6_cidr_blocks = ingress.value["ipv6_cidr_blocks"] + } + } + + dynamic "egress" { + for_each = var.alb_egress_rules + + content { + description = egress.value["description"] + from_port = egress.value["from_port"] + to_port = egress.value["to_port"] + protocol = egress.value["protocol"] + cidr_blocks = egress.value["cidr_blocks"] + ipv6_cidr_blocks = egress.value["ipv6_cidr_blocks"] + } + } +} + +resource "aws_security_group" "ec2" { + name = "${var.deployment_name}-ec2-security-group" + description = "Retool EC2 security group" + vpc_id = var.vpc_id + + dynamic "ingress" { + for_each = var.ec2_ingress_rules + content { + description = ingress.value["description"] + from_port = ingress.value["from_port"] + to_port = ingress.value["to_port"] + protocol = ingress.value["protocol"] + cidr_blocks = ingress.value["cidr_blocks"] + ipv6_cidr_blocks = ingress.value["ipv6_cidr_blocks"] + } + } + + dynamic "egress" { + for_each = var.ec2_egress_rules + + content { + description = egress.value["description"] + from_port = egress.value["from_port"] + to_port = egress.value["to_port"] + protocol = egress.value["protocol"] + cidr_blocks = egress.value["cidr_blocks"] + ipv6_cidr_blocks = egress.value["ipv6_cidr_blocks"] + } + } +} diff --git a/modules/aws_ecs_fargate/variables.tf b/modules/aws_ecs_fargate/variables.tf new file mode 100644 index 0000000..c0b67a9 --- /dev/null +++ b/modules/aws_ecs_fargate/variables.tf @@ -0,0 +1,280 @@ +variable "environment" { + description = "Environment to deploy in" + type = string +} + +variable "stage" { + description = "Stage to deploy in" + type = string +} + +variable "aws_region" { + type = string + default = "eu-central-1" + description = "AWS region. Defaults to `eu-central-1`" +} + +variable "domain_name" { + description = "Domain name to use" + type = string +} + +variable "node_env" { + type = string + default = "production" + description = "Value for NODE_ENV variable. Defaults to `production` and should not be set to any other value, regardless of environment." +} + +variable "vpc_id" { + type = string + description = "Select a VPC that allows instances access to the Internet." +} + +variable "vpc_cidr_block" { + type = string + description = "Select the CIDR block that will be allowed to access the RDS." +} + +variable "private_subnet_ids" { + type = list(string) + description = "Select at two subnets in your selected VPC." +} + +variable "public_subnet_ids" { + type = list(string) + description = "Select at two subnets in your selected VPC." +} + +# variable "ssh_key_name" { +# type = string +# description = "SSH key name for accessing EC2 instances" +# } + +# variable "instance_type" { +# type = string +# description = "ECS cluster instance type. Defaults to `t2.medium`" +# default = "t2.medium" +# } + +variable "max_instance_count" { + type = number + description = "Max number of EC2 instances. Defaults to 10." + default = 10 +} + +variable "min_instance_count" { + type = number + description = "Min/desired number of EC2 instances. Defaults to 2." + default = 2 +} + +variable "deployment_name" { + type = string + description = "Name prefix for created resources. Defaults to `retool`." + default = "retool" +} + +variable "retool_license_key" { + type = string + description = "Retool license key" +} + +variable "ecs_retool_image" { + type = string + description = "Container image for desired Retool version" + default = "tryretool/backend:2.69.18" +} + +variable "ecs_task_cpu" { + type = number + default = 1024 + description = "Amount of CPU provisioned for each task. Defaults to 1024." +} + +variable "ecs_task_memory" { + type = number + default = 2048 + description = "Amount of memory provisioned for each task. Defaults to 2048." +} + +variable "force_deployment" { + type = string + default = false + description = "Used to force the deployment even when the image and parameters are otherwised unchanged. Defaults to false." +} + +variable "ecs_insights_enabled" { + type = string + default = "enabled" + description = "Whether or not to enable ECS Container Insights. Defaults to `enabled`" +} + +variable "rds_username" { + type = string + default = "retool" + description = "Master username for the RDS instance. Defaults to Retool." +} + +variable "rds_performance_insights_enabled" { + type = bool + default = true + description = "Whether to enable Performance Insights for RDS. Defaults to true." +} + +variable "rds_performance_insights_retention_period" { + type = number + default = 14 + description = "The time in days to retain Performance Insights for RDS. Defaults to 14." +} + +variable "log_retention_in_days" { + type = number + default = 14 + description = "Number of days to retain logs in CloudWatch. Defaults to 14." +} + +variable "alb_idle_timeout" { + type = number + default = 60 + description = "The time in seconds that the connection is allowed to be idle. Defaults to 60." +} + +variable "cookie_insecure" { + type = bool + default = true + description = "Whether to allow insecure cookies. Should be turned off when serving on HTTPS. Defaults to true." +} + +variable "maximum_percent" { + type = number + default = 250 + description = "Maximum percentage of tasks to run during a deployment. Defaults to 250." +} + +variable "minimum_healthy_percent" { + type = number + default = 50 + description = "Minimum percentage of tasks to run during a deployment. Defaults to 50." +} + +variable "secret_length" { + type = number + default = 48 + description = "Length of secrets generated (e.g. ENCRYPTION_KEY, RDS_PASSWORD). Defaults to 48." +} + +variable "autoscaling_memory_reservation_target" { + type = number + default = 70.0 + description = "Memory reservation target for the Autoscaling Group. Defaults to 70.0." +} + +variable "additional_env_vars" { + type = list(map(string)) + default = [] + description = "Additional environment variables (e.g. BASE_DOMAIN)" +} + +variable "ec2_ingress_rules" { + type = list( + object({ + description = string + from_port = string + to_port = string + protocol = string + cidr_blocks = list(string) + ipv6_cidr_blocks = list(string) + }) + ) + default = [ + { + description = "Global HTTP inbound" + from_port = "3000" + to_port = "3000" + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } + ] + description = "Ingress rules for EC2 instances in autoscaling group" +} + + +variable "ec2_egress_rules" { + type = list( + object({ + description = string + from_port = string + to_port = string + protocol = string + cidr_blocks = list(string) + ipv6_cidr_blocks = list(string) + }) + ) + default = [ + { + description = "Global outbound" + from_port = "0" + to_port = "0" + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } + ] + description = "Egress rules for EC2 instances in autoscaling group" +} + + +variable "alb_ingress_rules" { + type = list( + object({ + description = string + from_port = string + to_port = string + protocol = string + cidr_blocks = list(string) + ipv6_cidr_blocks = list(string) + }) + ) + default = [ + { + description = "Global HTTP inbound" + from_port = "80" + to_port = "80" + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } + ] + description = "Ingress rules for load balancer" +} + + +variable "alb_egress_rules" { + type = list( + object({ + description = string + from_port = string + to_port = string + protocol = string + cidr_blocks = list(string) + ipv6_cidr_blocks = list(string) + }) + ) + default = [ + { + description = "Global outbound" + from_port = "0" + to_port = "0" + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } + ] + description = "Egress rules for load balancer" +} + +variable "management_api_key" { + type = string + description = "Management API key" +} diff --git a/modules/platform_network/main.tf b/modules/platform_network/main.tf new file mode 100644 index 0000000..700cdfa --- /dev/null +++ b/modules/platform_network/main.tf @@ -0,0 +1,25 @@ +data "aws_vpc" "main" { + tags = { + Name = "apideck--vpc--${var.stage}" + } +} + +data "aws_subnet" "main_private" { + count = 3 + + vpc_id = data.aws_vpc.main.id + tags = { + Name = "apideck--vpc--${var.stage}-private-subnet-${count.index}" + SubnetType = "private" + } +} + +data "aws_subnet" "main_public" { + count = 3 + + vpc_id = data.aws_vpc.main.id + tags = { + Name = "apideck--vpc--${var.stage}-public-subnet-${count.index}" + SubnetType = "public" + } +} diff --git a/modules/platform_network/outputs.tf b/modules/platform_network/outputs.tf new file mode 100644 index 0000000..0afcbd4 --- /dev/null +++ b/modules/platform_network/outputs.tf @@ -0,0 +1,16 @@ +output "vpc_id" { + value = data.aws_vpc.main.id +} + +output "vpc_cidr_block" { + value = data.aws_vpc.main.cidr_block +} + + +output "main_private_subnets" { + value = data.aws_subnet.main_private +} + +output "main_public_subnets" { + value = data.aws_subnet.main_public +} diff --git a/modules/platform_network/variables.tf b/modules/platform_network/variables.tf new file mode 100644 index 0000000..251664a --- /dev/null +++ b/modules/platform_network/variables.tf @@ -0,0 +1,15 @@ +variable "environment" { + description = "Environment to deploy in" + type = string +} +variable "stage" { + description = "Stage to deploy in" + type = string +} +variable "prefix" { + description = "prefix" + type = string +} +variable "project" { + description = "Full project or product name, to be used in tags or descriptions" +} diff --git a/terraform.tf b/terraform.tf new file mode 100644 index 0000000..7de0e43 --- /dev/null +++ b/terraform.tf @@ -0,0 +1,42 @@ +provider "aws" { + region = "eu-central-1" + profile = var.aws_profile + default_tags { + tags = local.tags + } +} + +provider "aws" { + alias = "us_east_1" + region = "us-east-1" + profile = var.aws_profile + default_tags { + tags = local.tags + } +} + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 4.0" + } + } + + backend "s3" { + dynamodb_table = "terraform-lock" + bucket = "apideck-terraform-s3" + key = "terraform/overwatch" + region = "eu-central-1" + encrypt = true + + profile = "apideck-staging" + assume_role = { + role_arn = "arn:aws:iam::708245472192:role/tf-state-role" + session_name = "Terraform" + external_id = "terraform-state" + } + } + required_version = ">= 0.13" +} + diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..c0e9b99 --- /dev/null +++ b/variables.tf @@ -0,0 +1,23 @@ +locals { + prefix = "overwatch" + project = "Overwatch" + environment = terraform.workspace + stage = terraform.workspace == "production" ? "production" : "staging" // production or staging + domain_name = terraform.workspace == "production" ? "apideck.com" : "stagingapideck.com" + tags = { + Application = local.project + Environment = terraform.workspace + } +} + +variable "aws_profile" { + type = string + description = "AWS Profile to use for deployment" + default = "" +} + +variable "client_id" { + type = string + description = "Google SSO Client ID" + default = "495594039277-u8690qm5okuca05c4upfehie6mqrtcvv.apps.googleusercontent.com" +}