diff --git a/flagsmith-on-ecs/README.md b/flagsmith-on-ecs/README.md new file mode 100644 index 0000000..c1d8d0b --- /dev/null +++ b/flagsmith-on-ecs/README.md @@ -0,0 +1,64 @@ +# Flagsmith AWS Starter kit + +## Deploying Flagsmith to AWS ECS (with whitenoise) + +### This is an example, how Flagsmith can be hosted, +### This is not production ready solution + +## AWS infrastructure consists of: +- IAM +- Route53 subdomain +- Networking: + - VPC + - Public and private subnets + - Routing tables + - Internet Gateway +- Security Groups +- Load Balancers, Listeners, and Target Groups +- IAM Roles and Policies +- ECS: + - Task Definition with flagsmith:latest image + - Cluster + - Service +- RDS +- Secrets with Parameter Store +- Logs + +## How to use this project + +1. Register AWS Route53 Hosted Zone \ + for example ```yourdomain.com``` +![Route53 hosted zone](img/route53.png) +2. Generate certificate with AWS Certificate Manager \ + with ```*.yourdomain.com``` pattern +![Certificate](img/AWS_certificate_manager.png) +3. Define your variables like hosted zone domain and more desired settings in **terraform.tfvars** file + ```bash + route53_hosted_zone = "yourdomain.com" + app_name = "flagsmith" + app_environment = "dev" + region = "eu-central-1" + allowed_hosts = "*" + ``` + + Currently containers are run on Fargate SPOT instances: + ecs_service.tf: + ```bash + capacity_provider_strategy { + capacity_provider = "FARGATE_SPOT" + weight = 100 + } + capacity_provider_strategy { + capacity_provider = "FARGATE" + weight = 0 + } + ``` +4. Generate plan for infrastructure ```terraform plan -out flagsmith.tfplan``` +5. Apply infrastructure by running ```terraform apply flagsmith.tfplan``` + +After a few minutes flagsmith will be available at your domain + +![Flagsmith online](img/flagsmith.png) + + +you can delete a whole setup by running ```terraform destroy --auto-approve``` diff --git a/flagsmith-on-ecs/img/AWS_certificate_manager.png b/flagsmith-on-ecs/img/AWS_certificate_manager.png new file mode 100644 index 0000000..cb6f4d4 Binary files /dev/null and b/flagsmith-on-ecs/img/AWS_certificate_manager.png differ diff --git a/flagsmith-on-ecs/img/flagsmith.png b/flagsmith-on-ecs/img/flagsmith.png new file mode 100644 index 0000000..2176c92 Binary files /dev/null and b/flagsmith-on-ecs/img/flagsmith.png differ diff --git a/flagsmith-on-ecs/img/route53.png b/flagsmith-on-ecs/img/route53.png new file mode 100644 index 0000000..bb402e9 Binary files /dev/null and b/flagsmith-on-ecs/img/route53.png differ diff --git a/flagsmith-on-ecs/terraform/01_provider.tf b/flagsmith-on-ecs/terraform/01_provider.tf new file mode 100644 index 0000000..5a3181f --- /dev/null +++ b/flagsmith-on-ecs/terraform/01_provider.tf @@ -0,0 +1,25 @@ +terraform { + required_version = ">=1.3.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~>4.63.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.5.1" + } + } +} + +provider "aws" { + region = var.region + + default_tags { + tags = { + Environment = var.app_environment + Project = var.app_name + } + } +} diff --git a/flagsmith-on-ecs/terraform/02_vpc.tf b/flagsmith-on-ecs/terraform/02_vpc.tf new file mode 100644 index 0000000..770b2d1 --- /dev/null +++ b/flagsmith-on-ecs/terraform/02_vpc.tf @@ -0,0 +1,44 @@ +# POC VPC + +data "aws_availability_zones" "available" { + state = "available" +} + +locals { + azs = slice(data.aws_availability_zones.available.names, 0, 2) + +} + +module "vpc" { + # https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest + source = "terraform-aws-modules/vpc/aws" + version = "3.18.1" + + name = "${var.app_name}-${var.app_environment}-vpc" + cidr = var.vpc_cidr + azs = local.azs + private_subnets = [for k, v in local.azs : cidrsubnet(var.vpc_cidr, 8, k + 10)] + public_subnets = [for k, v in local.azs : cidrsubnet(var.vpc_cidr, 8, k + 1)] + + enable_nat_gateway = true + single_nat_gateway = true + one_nat_gateway_per_az = false + enable_dns_support = true + enable_dns_hostnames = true + + + # for auto service discovery + # tags = { + + # } + + # public_subnet_tags = { + + # } + + # private_subnet_tags = { + + # } +} + + diff --git a/flagsmith-on-ecs/terraform/03_securitygroups.tf b/flagsmith-on-ecs/terraform/03_securitygroups.tf new file mode 100644 index 0000000..17abbeb --- /dev/null +++ b/flagsmith-on-ecs/terraform/03_securitygroups.tf @@ -0,0 +1,70 @@ +# ALB Security Group (Traffic Internet -> ALB) +resource "aws_security_group" "load-balancer" { + name = "load_balancer_security_group" + description = "Controls access to the ALB" + vpc_id = module.vpc.vpc_id + + # used for redirection to https + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + from_port = 443 + to_port = 443 + 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"] + } +} + +# ECS Security group (traffic ALB -> ECS) +resource "aws_security_group" "ecs" { + name = "ecs_security_group" + description = "Allows inbound access from the ALB only" + vpc_id = module.vpc.vpc_id + + ingress { + from_port = 8000 + to_port = 8000 + protocol = "tcp" + security_groups = [aws_security_group.load-balancer.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +# RDS Security Group (traffic ECS -> RDS) +resource "aws_security_group" "rds" { + name = "rds-security-group" + description = "Allows inbound access from ECS only" + vpc_id = module.vpc.vpc_id + + ingress { + protocol = "tcp" + from_port = "5432" + to_port = "5432" + security_groups = [aws_security_group.ecs.id] + } + + egress { + protocol = "-1" + from_port = 0 + to_port = 0 + cidr_blocks = ["0.0.0.0/0"] + } +} diff --git a/flagsmith-on-ecs/terraform/04_loadbalancer.tf b/flagsmith-on-ecs/terraform/04_loadbalancer.tf new file mode 100644 index 0000000..714d599 --- /dev/null +++ b/flagsmith-on-ecs/terraform/04_loadbalancer.tf @@ -0,0 +1,86 @@ +data "aws_acm_certificate" "certificate" { + domain = "*.${data.aws_route53_zone.selected.name}" + statuses = ["ISSUED"] +} + +# Load Balancer +resource "aws_lb" "ecs-alb" { + name = "${local.ecs_cluster_name}-alb" + load_balancer_type = "application" + internal = false + security_groups = [aws_security_group.load-balancer.id] + subnets = module.vpc.public_subnets +} + +# Target group +resource "aws_alb_target_group" "default-target-group" { + name = "${var.app_environment}-${var.app_name}-tg" + port = 8000 + protocol = "HTTP" + vpc_id = module.vpc.vpc_id + target_type = "ip" + + health_check { + path = var.health_check_path + port = "traffic-port" + healthy_threshold = 5 + unhealthy_threshold = 2 + timeout = 2 + interval = 5 + matcher = "200" + } +} + +# Listener, redirects HTTP to HTTPS +resource "aws_alb_listener" "ecs-alb-http-listener" { + load_balancer_arn = aws_lb.ecs-alb.id + port = "80" + protocol = "HTTP" + + default_action { + type = "redirect" + + redirect { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } +} + +# Listener (redirects traffic from the load balancer to the target group) +# Check for latest ssl policy https://docs.aws.amazon.com/elasticloadbalancing/latest/application/create-https-listener.html#describe-ssl-policies +resource "aws_alb_listener" "ecs-alb-https-listener" { + load_balancer_arn = aws_lb.ecs-alb.id + port = "443" + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + certificate_arn = data.aws_acm_certificate.certificate.arn + depends_on = [aws_alb_target_group.default-target-group] + + default_action { + type = "fixed-response" + + fixed_response { + content_type = "text/plain" + message_body = "Bad Request" + status_code = "400" + } + } + +} +# matches header with configured flagsmith subdomain +resource "aws_alb_listener_rule" "host_header" { + listener_arn = aws_alb_listener.ecs-alb-https-listener.arn + priority = 10 + + condition { + host_header { + values = ["${var.app_name}.${data.aws_route53_zone.selected.name}"] + } + } + action { + type = "forward" + target_group_arn = aws_alb_target_group.default-target-group.arn + } +} \ No newline at end of file diff --git a/flagsmith-on-ecs/terraform/05_iam.tf b/flagsmith-on-ecs/terraform/05_iam.tf new file mode 100644 index 0000000..101907e --- /dev/null +++ b/flagsmith-on-ecs/terraform/05_iam.tf @@ -0,0 +1,53 @@ +resource "aws_iam_role" "ecs_host_role" { + name = "${var.app_name}-ecs-host-role-${var.app_environment}" + assume_role_policy = file("policies/ecs-task-role.json") +} + +resource "aws_iam_role_policy" "ecs-host-role-policy" { + name = "${var.app_name}-ecs-host-role-policy" + + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "cloudwatch:PutMetricData", + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + "Resource" : "*" + }, + { + Effect = "Allow" + Action = ["ssm:GetParameters"], + Resource = ["arn:aws:ssm:${var.region}:${local.AWS_ACCOUNT_ID}:parameter/${var.app_name}/*"] + } + ] + } + ) + role = aws_iam_role.ecs_host_role.id +} + +resource "aws_iam_role" "ecs_task" { + name = "${var.app_name}-ecs-task" + assume_role_policy = file("policies/ecs-task-role.json") +} + +resource "aws_iam_role_policy" "ecs_task" { + name = "${var.app_name}-ecs-task-policy" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["ssm:GetParameters"], + Resource = ["arn:aws:ssm:${var.region}:${local.AWS_ACCOUNT_ID}:parameter/${var.app_name}/*"] + } + ] + }) + role = aws_iam_role.ecs_task.id +} diff --git a/flagsmith-on-ecs/terraform/06_logs.tf b/flagsmith-on-ecs/terraform/06_logs.tf new file mode 100644 index 0000000..c0a1f54 --- /dev/null +++ b/flagsmith-on-ecs/terraform/06_logs.tf @@ -0,0 +1,9 @@ +resource "aws_cloudwatch_log_group" "django-log-group" { + name = "/ecs/flagsmith" + retention_in_days = var.log_retention_in_days +} + +resource "aws_cloudwatch_log_stream" "django-log-stream" { + name = "flagsmith-log-stream" + log_group_name = aws_cloudwatch_log_group.django-log-group.name +} diff --git a/flagsmith-on-ecs/terraform/07_route53.tf b/flagsmith-on-ecs/terraform/07_route53.tf new file mode 100644 index 0000000..faceea6 --- /dev/null +++ b/flagsmith-on-ecs/terraform/07_route53.tf @@ -0,0 +1,17 @@ +data "aws_route53_zone" "selected" { + name = var.route53_hosted_zone + private_zone = false +} + +resource "aws_route53_record" "subdomain" { + zone_id = data.aws_route53_zone.selected.zone_id + name = "${var.app_name}.${data.aws_route53_zone.selected.name}" + type = "A" + + alias { + name = aws_lb.ecs-alb.dns_name + zone_id = aws_lb.ecs-alb.zone_id + evaluate_target_health = true + } + +} diff --git a/flagsmith-on-ecs/terraform/08_ecs.tf b/flagsmith-on-ecs/terraform/08_ecs.tf new file mode 100644 index 0000000..6c8f22b --- /dev/null +++ b/flagsmith-on-ecs/terraform/08_ecs.tf @@ -0,0 +1,3 @@ +resource "aws_ecs_cluster" "flagsmith" { + name = local.ecs_cluster_name +} diff --git a/flagsmith-on-ecs/terraform/09_ecs_service.tf b/flagsmith-on-ecs/terraform/09_ecs_service.tf new file mode 100644 index 0000000..fb4a2b3 --- /dev/null +++ b/flagsmith-on-ecs/terraform/09_ecs_service.tf @@ -0,0 +1,57 @@ +resource "aws_ecs_task_definition" "app" { + family = "flagsmith" + container_definitions = templatefile("templates/flagsmith.json", { + container_name = var.app_name + docker_image_url = var.docker_image_url + region = var.region + + allowed_hosts = var.allowed_hosts + settings_module = var.settings_module + AWS_ACCOUNT_ID = local.AWS_ACCOUNT_ID + app_environment = var.app_environment + app_name = var.app_name + }) + depends_on = [aws_db_instance.postgres] + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = var.cpu + memory = var.memory + task_role_arn = aws_iam_role.ecs_task.arn + execution_role_arn = aws_iam_role.ecs_host_role.arn + +} + +resource "aws_ecs_service" "flagsmith-svc" { + name = "${local.ecs_cluster_name}-service" + cluster = aws_ecs_cluster.flagsmith.id + task_definition = aws_ecs_task_definition.app.arn + desired_count = var.app_count + ## prevent race condition - iam + depends_on = [aws_alb_listener.ecs-alb-http-listener, aws_iam_role_policy.ecs_task] + + load_balancer { + target_group_arn = aws_alb_target_group.default-target-group.arn + container_name = var.app_name + container_port = 8000 + } + + capacity_provider_strategy { + capacity_provider = "FARGATE_SPOT" + weight = 100 + } + + capacity_provider_strategy { + capacity_provider = "FARGATE" + weight = 0 + } + + network_configuration { + assign_public_ip = true + security_groups = [aws_security_group.ecs.id] + subnets = module.vpc.private_subnets + } + + lifecycle { + ignore_changes = [task_definition, desired_count] + } +} \ No newline at end of file diff --git a/flagsmith-on-ecs/terraform/10_secrets.tf b/flagsmith-on-ecs/terraform/10_secrets.tf new file mode 100644 index 0000000..b236bec --- /dev/null +++ b/flagsmith-on-ecs/terraform/10_secrets.tf @@ -0,0 +1,52 @@ +data "aws_kms_key" "ssm_kms_key" { + key_id = var.ssm_kms_key +} + +resource "random_password" "rds_password" { + count = var.rds_password == "" ? 1 : 0 + length = 15 + special = true + override_special = "#%!&" +} + +resource "random_password" "django_secret_key" { + count = var.django_secret_key == "" ? 1 : 0 + length = 25 + special = true + override_special = "#%!&" +} + +resource "aws_ssm_parameter" "db_password" { + name = "/${var.app_name}/${var.app_environment}/rds_db_password" + value = var.rds_password == "" ? random_password.rds_password[0].result : var.rds_password + type = "SecureString" + key_id = data.aws_kms_key.ssm_kms_key.key_id +} + +resource "aws_ssm_parameter" "db_host" { + name = "/${var.app_name}/${var.app_environment}/rds_host" + value = aws_db_instance.postgres.address + type = "SecureString" + key_id = data.aws_kms_key.ssm_kms_key.key_id +} + +resource "aws_ssm_parameter" "django_secret_key" { + name = "/${var.app_name}/${var.app_environment}/django_secret_key" + value = var.django_secret_key == "" ? random_password.django_secret_key[0].result : var.django_secret_key + type = "SecureString" + key_id = data.aws_kms_key.ssm_kms_key.key_id +} + +resource "aws_ssm_parameter" "rds_username" { + name = "/${var.app_name}/${var.app_environment}/rds_db_username" + value = var.rds_username + type = "SecureString" + key_id = data.aws_kms_key.ssm_kms_key.key_id +} + +resource "aws_ssm_parameter" "rds_db_name" { + name = "/${var.app_name}/${var.app_environment}/rds_db_name" + value = var.rds_db_name + type = "SecureString" + key_id = data.aws_kms_key.ssm_kms_key.key_id +} diff --git a/flagsmith-on-ecs/terraform/11_rds.tf b/flagsmith-on-ecs/terraform/11_rds.tf new file mode 100644 index 0000000..a90a2af --- /dev/null +++ b/flagsmith-on-ecs/terraform/11_rds.tf @@ -0,0 +1,26 @@ + +resource "aws_db_subnet_group" "postgres" { + name = "${var.app_name}-postgres-sbg" + subnet_ids = module.vpc.private_subnets +} + + +resource "aws_db_instance" "postgres" { + identifier = "${var.app_name}-postgres" + db_name = var.rds_db_name + username = var.rds_username + password = aws_ssm_parameter.db_password.value + port = "5432" + engine = "postgres" + engine_version = "14" + instance_class = var.rds_instance_class + allocated_storage = "20" + storage_encrypted = false + vpc_security_group_ids = [aws_security_group.rds.id] + db_subnet_group_name = aws_db_subnet_group.postgres.name + multi_az = false + storage_type = "gp2" + publicly_accessible = false + backup_retention_period = 7 + skip_final_snapshot = true +} diff --git a/flagsmith-on-ecs/terraform/locals.tf b/flagsmith-on-ecs/terraform/locals.tf new file mode 100644 index 0000000..9f484a1 --- /dev/null +++ b/flagsmith-on-ecs/terraform/locals.tf @@ -0,0 +1,4 @@ +locals { + ecs_cluster_name = "${var.app_name}-${var.app_environment}-cluster" + AWS_ACCOUNT_ID = data.aws_caller_identity.current.account_id +} \ No newline at end of file diff --git a/flagsmith-on-ecs/terraform/outputs.tf b/flagsmith-on-ecs/terraform/outputs.tf new file mode 100644 index 0000000..b848029 --- /dev/null +++ b/flagsmith-on-ecs/terraform/outputs.tf @@ -0,0 +1,3 @@ +output "alb_hostname" { + value = aws_lb.ecs-alb.dns_name +} \ No newline at end of file diff --git a/flagsmith-on-ecs/terraform/policies/ecs-task-role.json b/flagsmith-on-ecs/terraform/policies/ecs-task-role.json new file mode 100644 index 0000000..946ab02 --- /dev/null +++ b/flagsmith-on-ecs/terraform/policies/ecs-task-role.json @@ -0,0 +1,14 @@ +{ + "Version": "2008-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": { + "Service": [ + "ecs-tasks.amazonaws.com" + ] + }, + "Effect": "Allow" + } + ] +} \ No newline at end of file diff --git a/flagsmith-on-ecs/terraform/sts.tf b/flagsmith-on-ecs/terraform/sts.tf new file mode 100644 index 0000000..5b663b9 --- /dev/null +++ b/flagsmith-on-ecs/terraform/sts.tf @@ -0,0 +1,3 @@ +data "aws_caller_identity" "current" { + +} \ No newline at end of file diff --git a/flagsmith-on-ecs/terraform/templates/flagsmith.json b/flagsmith-on-ecs/terraform/templates/flagsmith.json new file mode 100644 index 0000000..af08f60 --- /dev/null +++ b/flagsmith-on-ecs/terraform/templates/flagsmith.json @@ -0,0 +1,65 @@ +[ + { + "name": "${container_name}", + "image": "${docker_image_url}", + "essential": true, + "compatibilities": [ + "EC2", + "FARGATE" + ], + "portMappings": [ + { + "containerPort": 8000, + "hostPort": 8000, + "protocol": "tcp" + } + ], + "command": [ + "migrate-and-serve" + ], + "environment": [ + { + "name": "DJANGO_DB_PORT", + "value": "5432" + }, + { + "name": "DJANGO_ALLOWED_HOSTS", + "value": "${allowed_hosts}" + }, + { + "name": "DJANGO_SETTINGS_MODULE", + "value": "${settings_module}" + } + ], + "secrets": [ + { + "name": "DJANGO_SECRET_KEY", + "valueFrom": "arn:aws:ssm:${region}:${AWS_ACCOUNT_ID}:parameter/${app_name}/${app_environment}/django_secret_key" + }, + { + "name": "DJANGO_DB_NAME", + "valueFrom": "arn:aws:ssm:${region}:${AWS_ACCOUNT_ID}:parameter/${app_name}/${app_environment}/rds_db_name" + }, + { + "name": "DJANGO_DB_USER", + "valueFrom": "arn:aws:ssm:${region}:${AWS_ACCOUNT_ID}:parameter/${app_name}/${app_environment}/rds_db_username" + }, + { + "name": "DJANGO_DB_PASSWORD", + "valueFrom": "arn:aws:ssm:${region}:${AWS_ACCOUNT_ID}:parameter/${app_name}/${app_environment}/rds_db_password" + }, + { + "name": "DJANGO_DB_HOST", + "valueFrom": "arn:aws:ssm:${region}:${AWS_ACCOUNT_ID}:parameter/${app_name}/${app_environment}/rds_host" + } + ], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/flagsmith", + "awslogs-region": "${region}", + "awslogs-stream-prefix": "flagsmith-log-stream" + } + } + } +] \ No newline at end of file diff --git a/flagsmith-on-ecs/terraform/variables.tf b/flagsmith-on-ecs/terraform/variables.tf new file mode 100644 index 0000000..20ca8b9 --- /dev/null +++ b/flagsmith-on-ecs/terraform/variables.tf @@ -0,0 +1,121 @@ +variable "region" { + type = string + description = "The AWS region to create resources in." +} + +variable "app_environment" { + type = string + default = "dev" +} + +variable "app_name" { + type = string +} + + +# VPC + +variable "vpc_cidr" { + type = string + description = "CDIR of VPC" + default = "10.0.0.0/16" +} + +# load balancer + +variable "health_check_path" { + type = string + description = "Health check path for the default target group" + default = "/health/" +} + +# ECS Fargate + +variable "docker_image_url" { + type = string + description = "Docker image to run in the ECS cluster" + default = "flagsmith/flagsmith:latest" +} + +variable "app_count" { + type = number + description = "Number of Docker containers to run" + default = 2 +} + +# FIXME +variable "allowed_hosts" { + type = string + description = "Domain name for allowed hosts" +} + + +# logs + +variable "log_retention_in_days" { + type = number + default = 7 +} + +variable "settings_module" { + type = string + description = "Application settings" + default = "app.settings.production" +} + +# RDS +variable "ssm_kms_key" { + type = string + default = "alias/aws/ssm" + description = "KMS key to store encrypted password in AWS SSM Parameter store service" +} + +variable "rds_db_name" { + type = string + description = "RDS database name" + default = "flagsmithdb" +} + +variable "rds_username" { + type = string + description = "RDS database username" + default = "flagsmithdbuser" +} + +variable "rds_password" { + type = string + description = "RDS database password" + default = "" +} + +variable "rds_instance_class" { + type = string + description = "RDS instance type" + default = "db.t3.micro" +} + +# domain + +variable "route53_hosted_zone" { + type = string +} + +# Django + +variable "django_secret_key" { + type = string + description = "Django env. variable DJANGO_SECRET_KEY" + default = "" +} + +# Fargate's compute capacity profile + +variable "cpu" { + type = number + default = 512 +} + +variable "memory" { + type = number + default = 1024 +} \ No newline at end of file