From 8f8f63e2f0b82dd26420861f1b9af0d9f0f26cc7 Mon Sep 17 00:00:00 2001 From: Chris Doten Date: Sun, 29 Mar 2026 02:57:46 -0400 Subject: [PATCH 01/16] Adding .claude to .gitignore --- .gitignore | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 803785bac..33e1f748c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ .env* .Python .python-version -.vscode/ .webassets-cache *.pyc *.sublime-project @@ -22,4 +21,8 @@ node_modules/ pip-selfcheck.json share/ uploads/ -version.ini \ No newline at end of file +version.ini + +# Development +.claude/ +.vscode/ From 75c231156d3c18fac080a5650f304117e14573fc Mon Sep 17 00:00:00 2001 From: Chris Doten Date: Sun, 29 Mar 2026 03:48:57 -0400 Subject: [PATCH 02/16] Add initial Terraform setup and S3 attachments bucket --- .gitignore | 5 +++++ infra/terraform/.terraform.lock.hcl | 25 +++++++++++++++++++++++++ infra/terraform/README.md | 17 +++++++++++++++++ infra/terraform/main.tf | 24 ++++++++++++++++++++++++ infra/terraform/outputs.tf | 9 +++++++++ infra/terraform/providers.tf | 3 +++ infra/terraform/terraform.tfvars | 3 +++ infra/terraform/variables.tf | 17 +++++++++++++++++ infra/terraform/versions.tf | 10 ++++++++++ 9 files changed, 113 insertions(+) create mode 100644 infra/terraform/.terraform.lock.hcl create mode 100644 infra/terraform/README.md create mode 100644 infra/terraform/main.tf create mode 100644 infra/terraform/outputs.tf create mode 100644 infra/terraform/providers.tf create mode 100644 infra/terraform/terraform.tfvars create mode 100644 infra/terraform/variables.tf create mode 100644 infra/terraform/versions.tf diff --git a/.gitignore b/.gitignore index 33e1f748c..9fe5ca478 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,8 @@ version.ini # Development .claude/ .vscode/ + +# Terraform Setup +.terraform/ +*.tfstate +*.tfstate.* diff --git a/infra/terraform/.terraform.lock.hcl b/infra/terraform/.terraform.lock.hcl new file mode 100644 index 000000000..cdc1668d4 --- /dev/null +++ b/infra/terraform/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.0" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} diff --git a/infra/terraform/README.md b/infra/terraform/README.md new file mode 100644 index 000000000..e7aa66cce --- /dev/null +++ b/infra/terraform/README.md @@ -0,0 +1,17 @@ +# Apollo Terraform + +Terraform configuration for Apollo AWS infrastructure. + +## Current scope + +This currently manages: +- S3 bucket for attachments + +## Usage + +From this directory: + +```bash +terraform init +terraform plan +terraform apply diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 000000000..5d7877353 --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,24 @@ +locals { + name_prefix = "${var.project_name}-${var.environment}" + + common_tags = { + Project = var.project_name + Environment = var.environment + ManagedBy = "Terraform" + } +} + +resource "aws_s3_bucket" "apollo_attachments" { + bucket = "cdoten-apollo-dev-attachments" + + tags = local.common_tags +} + +resource "aws_s3_bucket_public_access_block" "apollo_attachments" { + bucket = aws_s3_bucket.apollo_attachments.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 000000000..0531fa33b --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,9 @@ +output "name_prefix" { + description = "Common prefix for resource names" + value = local.name_prefix +} + +output "attachments_bucket_name" { + description = "S3 bucket name for Apollo attachments" + value = aws_s3_bucket.apollo_attachments.bucket +} diff --git a/infra/terraform/providers.tf b/infra/terraform/providers.tf new file mode 100644 index 000000000..c9d7ccbde --- /dev/null +++ b/infra/terraform/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = var.aws_region +} diff --git a/infra/terraform/terraform.tfvars b/infra/terraform/terraform.tfvars new file mode 100644 index 000000000..83b1e416b --- /dev/null +++ b/infra/terraform/terraform.tfvars @@ -0,0 +1,3 @@ +aws_region = "us-east-1" +project_name = "apollo" +environment = "dev" diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 000000000..058ce9105 --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,17 @@ +variable "aws_region" { + type = string + description = "AWS region for Apollo infrastructure" + default = "us-east-1" +} + +variable "project_name" { + type = string + description = "Project name used in resource naming" + default = "apollo" +} + +variable "environment" { + type = string + description = "Environment name" + default = "dev" +} diff --git a/infra/terraform/versions.tf b/infra/terraform/versions.tf new file mode 100644 index 000000000..d75f2aa29 --- /dev/null +++ b/infra/terraform/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.8.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} From e62f2b1d1304d01f3d0c1ae103618e2a8b1d5957 Mon Sep 17 00:00:00 2001 From: Chris Doten Date: Sun, 29 Mar 2026 04:04:54 -0400 Subject: [PATCH 03/16] Created AWS terraform state bucket and repointed state info there --- infra/bootstrap/.terraform.lock.hcl | 25 +++++++++++++++++++ infra/bootstrap/main.tf | 37 +++++++++++++++++++++++++++++ infra/bootstrap/outputs.tf | 4 ++++ infra/bootstrap/providers.tf | 3 +++ infra/bootstrap/variables.tf | 11 +++++++++ infra/bootstrap/versions.tf | 10 ++++++++ infra/terraform/backend.tf | 9 +++++++ 7 files changed, 99 insertions(+) create mode 100644 infra/bootstrap/.terraform.lock.hcl create mode 100644 infra/bootstrap/main.tf create mode 100644 infra/bootstrap/outputs.tf create mode 100644 infra/bootstrap/providers.tf create mode 100644 infra/bootstrap/variables.tf create mode 100644 infra/bootstrap/versions.tf create mode 100644 infra/terraform/backend.tf diff --git a/infra/bootstrap/.terraform.lock.hcl b/infra/bootstrap/.terraform.lock.hcl new file mode 100644 index 000000000..cdc1668d4 --- /dev/null +++ b/infra/bootstrap/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.0" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} diff --git a/infra/bootstrap/main.tf b/infra/bootstrap/main.tf new file mode 100644 index 000000000..5ec8ac095 --- /dev/null +++ b/infra/bootstrap/main.tf @@ -0,0 +1,37 @@ +resource "aws_s3_bucket" "terraform_state" { + bucket = var.state_bucket_name + + tags = { + Project = "apollo" + Purpose = "terraform-state" + ManagedBy = "Terraform" + Environment = "shared" + } +} + +resource "aws_s3_bucket_public_access_block" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_versioning" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} diff --git a/infra/bootstrap/outputs.tf b/infra/bootstrap/outputs.tf new file mode 100644 index 000000000..a6a7a8a16 --- /dev/null +++ b/infra/bootstrap/outputs.tf @@ -0,0 +1,4 @@ +output "terraform_state_bucket_name" { + description = "S3 bucket name for Terraform remote state" + value = aws_s3_bucket.terraform_state.bucket +} diff --git a/infra/bootstrap/providers.tf b/infra/bootstrap/providers.tf new file mode 100644 index 000000000..c9d7ccbde --- /dev/null +++ b/infra/bootstrap/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = var.aws_region +} diff --git a/infra/bootstrap/variables.tf b/infra/bootstrap/variables.tf new file mode 100644 index 000000000..cedeca548 --- /dev/null +++ b/infra/bootstrap/variables.tf @@ -0,0 +1,11 @@ +variable "aws_region" { + type = string + description = "AWS region for Terraform backend resources" + default = "us-east-1" +} + +variable "state_bucket_name" { + type = string + description = "S3 bucket name for Terraform remote state" + default = "cdoten-apollo-terraform-state" +} diff --git a/infra/bootstrap/versions.tf b/infra/bootstrap/versions.tf new file mode 100644 index 000000000..d75f2aa29 --- /dev/null +++ b/infra/bootstrap/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.8.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} diff --git a/infra/terraform/backend.tf b/infra/terraform/backend.tf new file mode 100644 index 000000000..f733100e0 --- /dev/null +++ b/infra/terraform/backend.tf @@ -0,0 +1,9 @@ +terraform { + backend "s3" { + bucket = "cdoten-apollo-terraform-state" + key = "apollo/dev/terraform.tfstate" + region = "us-east-1" + use_lockfile = true + encrypt = true + } +} From 44f63edc988553b7848998bf3ea683ba2633159b Mon Sep 17 00:00:00 2001 From: Chris Doten Date: Sun, 29 Mar 2026 14:50:12 -0400 Subject: [PATCH 04/16] Add initial Terraform AWS infrastructure for Apollo Set up Terraform under infra/terraform and a bootstrap stack under infra/bootstrap for remote state. Create S3-backed remote state with versioning, encryption, and locking, and add the Apollo attachments bucket with encryption, versioning, and blocked public access. Build the initial AWS network foundation with a VPC, public/private subnets across two AZs, an internet gateway, and public routing. Add security groups for the ALB, web, worker, RDS, and Redis tiers with explicit SG-to-SG traffic rules. Provision a private RDS PostgreSQL instance and DB subnet group, and add an infra README documenting the current architecture and design goal of keeping the stack inexpensive but stable. --- infra/README.md | 147 +++++++++++++++++ infra/terraform/main.tf | 263 ++++++++++++++++++++++++++++++- infra/terraform/outputs.tf | 75 +++++++++ infra/terraform/terraform.tfvars | 1 + infra/terraform/variables.tf | 72 +++++++++ 5 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 infra/README.md diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 000000000..c0ef3ce9d --- /dev/null +++ b/infra/README.md @@ -0,0 +1,147 @@ +# Apollo Infrastructure + +This directory contains the Terraform configuration for Apollo's AWS deployment. + +## Structure + +- `infra/bootstrap/` creates and manages the S3 bucket used for Terraform remote state. +- `infra/terraform/` contains the main Terraform stack for Apollo infrastructure. + +## Current architecture + +The current AWS layout is intentionally modest: cheap where possible, but stable enough not to be a constant operational headache. + +### State and storage + +- Terraform remote state is stored in an S3 bucket managed by the bootstrap stack. +- Apollo attachments are stored in a separate S3 bucket. +- Both buckets have public access blocked. +- The attachments bucket has default encryption and versioning enabled. + +### Networking + +The main Terraform stack currently creates: + +- one VPC +- two public subnets +- two private app subnets +- two private data subnets +- one internet gateway +- one public route table associated to the public subnets + +The intended tiering is: + +- **public subnets** for the load balancer +- **private app subnets** for ECS tasks +- **private data subnets** for RDS and Redis + +### Security model + +Security groups are defined for: + +- ALB +- web tasks +- worker tasks +- RDS PostgreSQL +- Redis + +The intended traffic flow is: + +- internet -> ALB on `443` +- ALB -> web tasks on the application port +- web and worker tasks -> PostgreSQL on `5432` +- web and worker tasks -> Redis on `6379` + +The worker service is not intended to receive direct inbound traffic. + +### Database + +- PostgreSQL runs on Amazon RDS. +- The DB instance is in the private data subnets. +- The DB is not publicly accessible. +- The current configuration is tuned for development / early infrastructure bring-up rather than hardened production. +- Apollo requires PostGIS support. + +## Design priorities + +This infrastructure is being built with the following priority order: + +1. stable enough not to require constant babysitting +2. as inexpensive as practical +3. only then, additional elegance or scale + +In practice, that currently means: + +- preferring managed services when they materially reduce operational pain +- avoiding premature high-availability spend where it is not yet justified +- keeping the network and security layout sane from the start +- using S3 instead of EFS for attachments + +A useful shorthand for the approach is **low pain per dollar**. + +## Current dev-stage compromises + +Some current settings are appropriate for early-stage or development use, but should be revisited before treating this as real production infrastructure. + +Examples include: + +- RDS `skip_final_snapshot = true` +- RDS `deletion_protection = false` +- single-AZ database deployment +- secrets currently simple enough for bootstrapping rather than a final production secret-management pattern + +## Working with Terraform + +### Bootstrap stack + +Use `infra/bootstrap/` only for infrastructure that supports Terraform itself, primarily the remote state bucket. + +Typical workflow: + +```bash +cd infra/bootstrap +terraform init +terraform plan +terraform apply +``` + +### Main Apollo stack + +Use `infra/terraform/` for the actual Apollo infrastructure. + +Typical workflow: + +```bash +cd infra/terraform +terraform init +terraform plan +terraform apply +``` + +## Notes on state + +- Terraform state for the main stack is stored remotely in S3. +- The local machine is no longer the source of truth for Terraform state. +- `.terraform.lock.hcl` should be committed. +- local `*.tfstate` files should not be committed. + +## Near-term expected additions + +The current stack is not yet complete. Likely next pieces include: + +- Redis +- ECR repository +- ECS services for web and worker +- ALB +- certificate and DNS wiring +- application-level migration / initialization flow +- confirmation that PostGIS is enabled as Apollo expects + +## Intent of the split between `bootstrap` and `terraform` + +This split is deliberate. + +- `bootstrap` manages the infrastructure Terraform needs in order to operate safely. +- `terraform` manages Apollo itself. + +The main Apollo stack should not try to own the backend bucket that stores its own state. diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 5d7877353..97df96b20 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -5,9 +5,9 @@ locals { Project = var.project_name Environment = var.environment ManagedBy = "Terraform" + Owner = "cdoten" } } - resource "aws_s3_bucket" "apollo_attachments" { bucket = "cdoten-apollo-dev-attachments" @@ -22,3 +22,264 @@ resource "aws_s3_bucket_public_access_block" "apollo_attachments" { ignore_public_acls = true restrict_public_buckets = true } + +resource "aws_vpc" "apollo" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-vpc" + }) +} + +resource "aws_subnet" "public" { + count = length(var.public_subnet_cidrs) + + vpc_id = aws_vpc.apollo.id + cidr_block = var.public_subnet_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + map_public_ip_on_launch = true + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-public-${count.index + 1}" + Tier = "public" + }) +} + +resource "aws_subnet" "private_app" { + count = length(var.private_app_subnet_cidrs) + + vpc_id = aws_vpc.apollo.id + cidr_block = var.private_app_subnet_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-private-app-${count.index + 1}" + Tier = "private-app" + }) +} + +resource "aws_subnet" "private_data" { + count = length(var.private_data_subnet_cidrs) + + vpc_id = aws_vpc.apollo.id + cidr_block = var.private_data_subnet_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-private-data-${count.index + 1}" + Tier = "private-data" + }) +} + +resource "aws_internet_gateway" "apollo" { + vpc_id = aws_vpc.apollo.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-igw" + }) +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.apollo.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-public-rt" + Tier = "public" + }) +} + +resource "aws_route" "public_internet_access" { + route_table_id = aws_route_table.public.id + destination_cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.apollo.id +} + +resource "aws_route_table_association" "public" { + count = length(aws_subnet.public) + + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + + +resource "aws_security_group" "alb" { + name = "${local.name_prefix}-alb-sg" + description = "Security group for the Apollo load balancer" + vpc_id = aws_vpc.apollo.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-alb-sg" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "alb_https_in" { + security_group_id = aws_security_group.alb.id + cidr_ipv4 = "0.0.0.0/0" + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + description = "Allow HTTPS from the internet" +} + +resource "aws_vpc_security_group_egress_rule" "alb_all_out" { + security_group_id = aws_security_group.alb.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" + description = "Allow all outbound traffic" +} + +resource "aws_security_group" "web" { + name = "${local.name_prefix}-web-sg" + description = "Security group for Apollo web tasks" + vpc_id = aws_vpc.apollo.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-web-sg" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "web_from_alb" { + security_group_id = aws_security_group.web.id + referenced_security_group_id = aws_security_group.alb.id + from_port = var.app_port + to_port = var.app_port + ip_protocol = "tcp" + description = "Allow app traffic from the ALB" +} + +resource "aws_vpc_security_group_egress_rule" "web_all_out" { + security_group_id = aws_security_group.web.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" + description = "Allow all outbound traffic" +} + +resource "aws_security_group" "worker" { + name = "${local.name_prefix}-worker-sg" + description = "Security group for Apollo worker tasks" + vpc_id = aws_vpc.apollo.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-worker-sg" + }) +} + +resource "aws_vpc_security_group_egress_rule" "worker_all_out" { + security_group_id = aws_security_group.worker.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" + description = "Allow all outbound traffic" +} + +resource "aws_security_group" "rds" { + name = "${local.name_prefix}-rds-sg" + description = "Security group for Apollo PostgreSQL" + vpc_id = aws_vpc.apollo.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-rds-sg" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "rds_from_web" { + security_group_id = aws_security_group.rds.id + referenced_security_group_id = aws_security_group.web.id + from_port = 5432 + to_port = 5432 + ip_protocol = "tcp" + description = "Allow PostgreSQL from web tasks" +} + +resource "aws_vpc_security_group_ingress_rule" "rds_from_worker" { + security_group_id = aws_security_group.rds.id + referenced_security_group_id = aws_security_group.worker.id + from_port = 5432 + to_port = 5432 + ip_protocol = "tcp" + description = "Allow PostgreSQL from worker tasks" +} + +resource "aws_vpc_security_group_egress_rule" "rds_all_out" { + security_group_id = aws_security_group.rds.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" + description = "Allow all outbound traffic" +} + +resource "aws_security_group" "redis" { + name = "${local.name_prefix}-redis-sg" + description = "Security group for Apollo Redis" + vpc_id = aws_vpc.apollo.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-redis-sg" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "redis_from_web" { + security_group_id = aws_security_group.redis.id + referenced_security_group_id = aws_security_group.web.id + from_port = 6379 + to_port = 6379 + ip_protocol = "tcp" + description = "Allow Redis from web tasks" +} + +resource "aws_vpc_security_group_ingress_rule" "redis_from_worker" { + security_group_id = aws_security_group.redis.id + referenced_security_group_id = aws_security_group.worker.id + from_port = 6379 + to_port = 6379 + ip_protocol = "tcp" + description = "Allow Redis from worker tasks" +} + +resource "aws_vpc_security_group_egress_rule" "redis_all_out" { + security_group_id = aws_security_group.redis.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" + description = "Allow all outbound traffic" +} + +resource "aws_db_subnet_group" "apollo" { + name = "${local.name_prefix}-db-subnet-group" + subnet_ids = aws_subnet.private_data[*].id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-db-subnet-group" + }) +} + +resource "aws_db_instance" "apollo" { + identifier = "${local.name_prefix}-postgres" + + engine = "postgres" + engine_version = var.db_engine_version + instance_class = var.db_instance_class + + allocated_storage = var.db_allocated_storage + storage_type = "gp3" + db_name = var.db_name + username = var.db_username + password = var.db_password + port = 5432 + + db_subnet_group_name = aws_db_subnet_group.apollo.name + vpc_security_group_ids = [aws_security_group.rds.id] + + publicly_accessible = false + multi_az = false + + skip_final_snapshot = true + deletion_protection = false + + backup_retention_period = 7 + + auto_minor_version_upgrade = true + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-postgres" + }) +} \ No newline at end of file diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index 0531fa33b..751f8328c 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -7,3 +7,78 @@ output "attachments_bucket_name" { description = "S3 bucket name for Apollo attachments" value = aws_s3_bucket.apollo_attachments.bucket } + +output "vpc_id" { + description = "ID of the Apollo VPC" + value = aws_vpc.apollo.id +} + +output "vpc_cidr" { + description = "CIDR block of the Apollo VPC" + value = aws_vpc.apollo.cidr_block +} + +output "public_subnet_ids" { + description = "IDs of the public subnets" + value = aws_subnet.public[*].id +} + +output "private_app_subnet_ids" { + description = "IDs of the private app subnets" + value = aws_subnet.private_app[*].id +} + +output "private_data_subnet_ids" { + description = "IDs of the private data subnets" + value = aws_subnet.private_data[*].id +} + +output "internet_gateway_id" { + description = "ID of the Apollo internet gateway" + value = aws_internet_gateway.apollo.id +} + +output "public_route_table_id" { + description = "ID of the public route table" + value = aws_route_table.public.id +} + +output "alb_security_group_id" { + description = "ID of the ALB security group" + value = aws_security_group.alb.id +} + +output "web_security_group_id" { + description = "ID of the web task security group" + value = aws_security_group.web.id +} + +output "worker_security_group_id" { + description = "ID of the worker task security group" + value = aws_security_group.worker.id +} + +output "rds_security_group_id" { + description = "ID of the RDS security group" + value = aws_security_group.rds.id +} + +output "redis_security_group_id" { + description = "ID of the Redis security group" + value = aws_security_group.redis.id +} + +output "db_instance_endpoint" { + description = "Endpoint of the Apollo PostgreSQL instance" + value = aws_db_instance.apollo.endpoint +} + +output "db_instance_address" { + description = "Address of the Apollo PostgreSQL instance" + value = aws_db_instance.apollo.address +} + +output "db_name" { + description = "Database name for Apollo" + value = aws_db_instance.apollo.db_name +} \ No newline at end of file diff --git a/infra/terraform/terraform.tfvars b/infra/terraform/terraform.tfvars index 83b1e416b..48df67bff 100644 --- a/infra/terraform/terraform.tfvars +++ b/infra/terraform/terraform.tfvars @@ -1,3 +1,4 @@ aws_region = "us-east-1" project_name = "apollo" environment = "dev" +db_password = "hollowelkmine" diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index 058ce9105..103861a37 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -15,3 +15,75 @@ variable "environment" { description = "Environment name" default = "dev" } + +variable "vpc_cidr" { + type = string + description = "CIDR block for the Apollo VPC" + default = "10.0.0.0/16" +} + +variable "availability_zones" { + type = list(string) + description = "Availability zones for Apollo infrastructure" + default = ["us-east-1a", "us-east-1b"] +} + +variable "public_subnet_cidrs" { + type = list(string) + description = "CIDR blocks for public subnets" + default = ["10.0.1.0/24", "10.0.2.0/24"] +} + +variable "private_app_subnet_cidrs" { + type = list(string) + description = "CIDR blocks for private app subnets" + default = ["10.0.11.0/24", "10.0.12.0/24"] +} + +variable "private_data_subnet_cidrs" { + type = list(string) + description = "CIDR blocks for private data subnets" + default = ["10.0.21.0/24", "10.0.22.0/24"] +} + +variable "app_port" { + type = number + description = "Port the Apollo web application listens on" + default = 5000 +} + +variable "db_name" { + type = string + description = "Initial Apollo database name" + default = "apollo" +} + +variable "db_username" { + type = string + description = "Master username for the Apollo database" + default = "apollo_admin" +} + +variable "db_password" { + type = string + description = "Master password for the Apollo database" + sensitive = true +} + +variable "db_instance_class" { + type = string + description = "RDS instance class for Apollo PostgreSQL" + default = "db.t4g.micro" +} + +variable "db_allocated_storage" { + type = number + description = "Allocated storage in GiB for the Apollo database" + default = 20 +} + +variable "db_engine_version" { + type = string + description = "PostgreSQL engine version for Apollo" + default = "16" +} \ No newline at end of file From e8bd9984b1c465432596498eec4535bfef79b46f Mon Sep 17 00:00:00 2001 From: Chris Doten Date: Mon, 30 Mar 2026 10:44:44 -0400 Subject: [PATCH 05/16] Further terraform implementation, including initial Redis work. Updated the readme to match. --- infra/README.md | 22 +++++++++++++++------- infra/terraform/main.tf | 32 ++++++++++++++++++++++++++++++++ infra/terraform/outputs.tf | 10 ++++++++++ infra/terraform/variables.tf | 18 ++++++++++++++++++ 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/infra/README.md b/infra/README.md index c0ef3ce9d..6e8ea5a9f 100644 --- a/infra/README.md +++ b/infra/README.md @@ -2,6 +2,8 @@ This directory contains the Terraform configuration for Apollo's AWS deployment. +Apollo is expected to run as a Flask/Gunicorn web service plus a separate Celery worker, backed by PostgreSQL/PostGIS, Redis, and S3 attachments. + ## Structure - `infra/bootstrap/` creates and manages the S3 bucket used for Terraform remote state. @@ -29,7 +31,7 @@ The main Terraform stack currently creates: - one internet gateway - one public route table associated to the public subnets -The intended tiering is: +The intended network tiering is: - **public subnets** for the load balancer - **private app subnets** for ECS tasks @@ -37,7 +39,7 @@ The intended tiering is: ### Security model -Security groups are defined for: +Security groups are defined for the future runtime layout: - ALB - web tasks @@ -45,7 +47,7 @@ Security groups are defined for: - RDS PostgreSQL - Redis -The intended traffic flow is: +The intended traffic flow, once the runtime layer is in place, is: - internet -> ALB on `443` - ALB -> web tasks on the application port @@ -60,7 +62,13 @@ The worker service is not intended to receive direct inbound traffic. - The DB instance is in the private data subnets. - The DB is not publicly accessible. - The current configuration is tuned for development / early infrastructure bring-up rather than hardened production. -- Apollo requires PostGIS support. +- Migrations should ensure the PostGIS extension is enabled as Apollo requires. + +### Redis + +- Redis runs on Amazon ElastiCache. +- Redis is in the private data subnets. +- Redis is intended for Apollo's Celery/background-task queueing. ## Design priorities @@ -129,13 +137,13 @@ terraform apply The current stack is not yet complete. Likely next pieces include: -- Redis +- a one-off application migration task during deployment - ECR repository - ECS services for web and worker - ALB - certificate and DNS wiring -- application-level migration / initialization flow -- confirmation that PostGIS is enabled as Apollo expects +- ECS task definitions and runtime configuration for web, worker, and migration +- confirmation that Apollo migrations enable PostGIS cleanly in the AWS environment ## Intent of the split between `bootstrap` and `terraform` diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 97df96b20..849ceed8c 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -282,4 +282,36 @@ resource "aws_db_instance" "apollo" { tags = merge(local.common_tags, { Name = "${local.name_prefix}-postgres" }) +} + +resource "aws_elasticache_subnet_group" "apollo" { + name = "${local.name_prefix}-redis-subnet-group" + subnet_ids = aws_subnet.private_data[*].id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-redis-subnet-group" + }) +} + +resource "aws_elasticache_replication_group" "apollo" { + replication_group_id = "${local.name_prefix}-redis" + description = "Apollo Redis replication group" + engine = "redis" + engine_version = var.redis_engine_version + node_type = var.redis_node_type + port = var.redis_port + + num_cache_clusters = 1 + automatic_failover_enabled = false + multi_az_enabled = false + + subnet_group_name = aws_elasticache_subnet_group.apollo.name + security_group_ids = [aws_security_group.redis.id] + + at_rest_encryption_enabled = true + transit_encryption_enabled = false + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-redis" + }) } \ No newline at end of file diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index 751f8328c..68c85ba9a 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -81,4 +81,14 @@ output "db_instance_address" { output "db_name" { description = "Database name for Apollo" value = aws_db_instance.apollo.db_name +} + +output "redis_primary_endpoint_address" { + description = "Primary endpoint address of the Apollo Redis replication group" + value = aws_elasticache_replication_group.apollo.primary_endpoint_address +} + +output "redis_port" { + description = "Port of the Apollo Redis replication group" + value = aws_elasticache_replication_group.apollo.port } \ No newline at end of file diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index 103861a37..51c078b98 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -86,4 +86,22 @@ variable "db_engine_version" { type = string description = "PostgreSQL engine version for Apollo" default = "16" +} + +variable "redis_node_type" { + type = string + description = "ElastiCache node type for Apollo Redis" + default = "cache.t4g.micro" +} + +variable "redis_engine_version" { + type = string + description = "Redis OSS engine version for Apollo Redis" + default = "7.1" +} + +variable "redis_port" { + type = number + description = "Port for Apollo Redis" + default = 6379 } \ No newline at end of file From d7175d08db3e6c50c6389584c99a4ee28bf51af2 Mon Sep 17 00:00:00 2001 From: Chris Doten Date: Tue, 31 Mar 2026 00:06:05 -0400 Subject: [PATCH 06/16] Add ECS runtime infrastructure for Apollo deployment - add ECR repository management to Terraform - add ECS cluster and task/task-execution IAM roles - add Secrets Manager secrets for app runtime - add CloudWatch log groups for migration, web, and worker - add ECS task definitions for migration, web, and worker - add ALB, target group, listeners, and ECS services for web/worker - expand variables and outputs for runtime and public hostname configuration --- .gitignore | 1 + infra/terraform/main.tf | 457 ++++++++++++++++++ infra/terraform/outputs.tf | 70 +++ ...raform.tfvars => terraform.tfvars.example} | 3 +- infra/terraform/variables.tf | 54 +++ 5 files changed, 584 insertions(+), 1 deletion(-) rename infra/terraform/{terraform.tfvars => terraform.tfvars.example} (50%) diff --git a/.gitignore b/.gitignore index 9fe5ca478..de3e881df 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,6 @@ version.ini # Terraform Setup .terraform/ +infra/terraform/terraform.tfvars *.tfstate *.tfstate.* diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 849ceed8c..46ee162c0 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -314,4 +314,461 @@ resource "aws_elasticache_replication_group" "apollo" { tags = merge(local.common_tags, { Name = "${local.name_prefix}-redis" }) +} + +resource "aws_ecr_repository" "apollo" { + name = "apollo" + + image_scanning_configuration { + scan_on_push = true + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-ecr" + }) +} + +resource "aws_ecs_cluster" "apollo" { + name = local.name_prefix + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-ecs-cluster" + }) +} + + +data "aws_iam_policy_document" "ecs_tasks_assume_role" { + statement { + effect = "Allow" + + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + + actions = ["sts:AssumeRole"] + } +} + +resource "aws_iam_role" "ecs_task_execution" { + name = "${local.name_prefix}-ecs-task-execution-role" + assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume_role.json + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-ecs-task-execution-role" + }) +} + +resource "aws_iam_role_policy_attachment" "ecs_task_execution_managed" { + role = aws_iam_role.ecs_task_execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role" "apollo_task" { + name = "${local.name_prefix}-apollo-task-role" + assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume_role.json + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-apollo-task-role" + }) +} + +data "aws_iam_policy_document" "apollo_task_s3" { + statement { + effect = "Allow" + actions = [ + "s3:ListBucket" + ] + resources = [ + aws_s3_bucket.apollo_attachments.arn + ] + } + + statement { + effect = "Allow" + actions = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ] + resources = [ + "${aws_s3_bucket.apollo_attachments.arn}/*" + ] + } +} + +resource "aws_iam_role_policy" "apollo_task_s3" { + name = "${local.name_prefix}-apollo-task-s3" + role = aws_iam_role.apollo_task.id + policy = data.aws_iam_policy_document.apollo_task_s3.json +} + +resource "aws_cloudwatch_log_group" "apollo_migration" { + name = "/ecs/${local.name_prefix}-migration" + retention_in_days = 14 + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-migration-logs" + }) +} + +resource "aws_cloudwatch_log_group" "apollo_web" { + name = "/ecs/${local.name_prefix}-web" + retention_in_days = 14 + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-web-logs" + }) +} + +resource "aws_cloudwatch_log_group" "apollo_worker" { + name = "/ecs/${local.name_prefix}-worker" + retention_in_days = 14 + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-worker-logs" + }) +} + +locals { + apollo_common_environment = [ + { + name = "PREFERRED_URL_SCHEME" + value = "https" + }, + { + name = "DATABASE_HOSTNAME" + value = aws_db_instance.apollo.address + }, + { + name = "DATABASE_NAME" + value = aws_db_instance.apollo.db_name + }, + { + name = "DATABASE_USERNAME" + value = var.db_username + }, + { + name = "REDIS_HOSTNAME" + value = aws_elasticache_replication_group.apollo.primary_endpoint_address + }, + { + name = "REDIS_DATABASE" + value = "0" + }, + { + name = "ATTACHMENTS_USE_S3" + value = "true" + }, + { + name = "AWS_DEFAULT_BUCKET" + value = aws_s3_bucket.apollo_attachments.bucket + }, + { + name = "AWS_DEFAULT_REGION" + value = var.aws_region + }, + { + name = "TIMEZONE" + value = var.timezone + }, + { + name = "DEFAULT_EMAIL_SENDER" + value = var.default_email_sender + }, + { + name = "FLASK_ENV" + value = "production" + }, + { + name = "FLASK_APP" + value = "apollo.runner" + } + ] +} + +resource "aws_ecs_task_definition" "apollo_migration" { + family = "${local.name_prefix}-migration" + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = tostring(var.ecs_task_cpu) + memory = tostring(var.ecs_task_memory) + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.apollo_task.arn + + container_definitions = jsonencode([ + { + name = "apollo-migration" + image = var.apollo_image_uri + essential = true + command = ["flask", "db", "upgrade"] + + environment = local.apollo_common_environment + + secrets = [ + { + name = "SECRET_KEY" + valueFrom = aws_secretsmanager_secret_version.apollo_secret_key.arn + }, + { + name = "DATABASE_PASSWORD" + valueFrom = aws_secretsmanager_secret_version.apollo_db_password.arn + } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.apollo_migration.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "ecs" + } + } + } + ]) + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-migration-taskdef" + }) +} + +resource "aws_ecs_task_definition" "apollo_web" { + family = "${local.name_prefix}-web" + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = tostring(var.ecs_task_cpu) + memory = tostring(var.ecs_task_memory) + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.apollo_task.arn + + container_definitions = jsonencode([ + { + name = "apollo-web" + image = var.apollo_image_uri + essential = true + command = ["gunicorn", "-c", "gunicorn.py", "apollo.runner"] + + portMappings = [ + { + containerPort = var.app_port + hostPort = var.app_port + protocol = "tcp" + } + ] + + environment = local.apollo_common_environment + + secrets = [ + { + name = "SECRET_KEY" + valueFrom = aws_secretsmanager_secret_version.apollo_secret_key.arn + }, + { + name = "DATABASE_PASSWORD" + valueFrom = aws_secretsmanager_secret_version.apollo_db_password.arn + } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.apollo_web.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "ecs" + } + } + } + ]) + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-web-taskdef" + }) +} + +resource "aws_ecs_task_definition" "apollo_worker" { + family = "${local.name_prefix}-worker" + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = tostring(var.ecs_task_cpu) + memory = tostring(var.ecs_task_memory) + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.apollo_task.arn + + container_definitions = jsonencode([ + { + name = "apollo-worker" + image = var.apollo_image_uri + essential = true + command = ["celery", "--app=apollo.runner", "worker", "--beat", "--loglevel=WARNING", "--concurrency=2", "--without-gossip", "--without-mingle", "--optimization=fair"] + + environment = local.apollo_common_environment + + secrets = [ + { + name = "SECRET_KEY" + valueFrom = aws_secretsmanager_secret_version.apollo_secret_key.arn + }, + { + name = "DATABASE_PASSWORD" + valueFrom = aws_secretsmanager_secret_version.apollo_db_password.arn + } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.apollo_worker.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "ecs" + } + } + } + ]) + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-worker-taskdef" + }) +} + +resource "aws_secretsmanager_secret" "apollo_secret_key" { + name = "${local.name_prefix}/apollo/secret-key" + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-apollo-secret-key" + }) +} + +resource "aws_secretsmanager_secret_version" "apollo_secret_key" { + secret_id = aws_secretsmanager_secret.apollo_secret_key.id + secret_string = var.secret_key +} + +resource "aws_secretsmanager_secret" "apollo_db_password" { + name = "${local.name_prefix}/apollo/db-password" + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-apollo-db-password" + }) +} + +resource "aws_secretsmanager_secret_version" "apollo_db_password" { + secret_id = aws_secretsmanager_secret.apollo_db_password.id + secret_string = var.db_password +} + +resource "aws_lb" "apollo" { + name = "${local.name_prefix}-alb" + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.alb.id] + subnets = aws_subnet.public[*].id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-alb" + }) +} + +resource "aws_lb_target_group" "apollo_web" { + name = "${local.name_prefix}-web-tg" + port = var.app_port + protocol = "HTTP" + target_type = "ip" + vpc_id = aws_vpc.apollo.id + + health_check { + enabled = true + path = var.health_check_path + port = "traffic-port" + protocol = "HTTP" + matcher = "200-399" + healthy_threshold = 2 + unhealthy_threshold = 5 + timeout = 5 + interval = 30 + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-web-tg" + }) +} + +resource "aws_lb_listener" "apollo_https" { + load_balancer_arn = aws_lb.apollo.arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-2016-08" + certificate_arn = var.apollo_certificate_arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.apollo_web.arn + } +} + +resource "aws_lb_listener" "apollo_http_redirect" { + load_balancer_arn = aws_lb.apollo.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "redirect" + + redirect { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } +} + +resource "aws_ecs_service" "apollo_web" { + name = "${local.name_prefix}-web" + cluster = aws_ecs_cluster.apollo.id + task_definition = aws_ecs_task_definition.apollo_web.arn + desired_count = 1 + launch_type = "FARGATE" + + deployment_minimum_healthy_percent = 50 + deployment_maximum_percent = 200 + + network_configuration { + subnets = aws_subnet.private_app[*].id + security_groups = [aws_security_group.web.id] + assign_public_ip = false + } + + load_balancer { + target_group_arn = aws_lb_target_group.apollo_web.arn + container_name = "apollo-web" + container_port = var.app_port + } + + depends_on = [ + aws_lb_listener.apollo_https + ] + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-web-service" + }) +} + +resource "aws_ecs_service" "apollo_worker" { + name = "${local.name_prefix}-worker" + cluster = aws_ecs_cluster.apollo.id + task_definition = aws_ecs_task_definition.apollo_worker.arn + desired_count = 1 + launch_type = "FARGATE" + + deployment_minimum_healthy_percent = 0 + deployment_maximum_percent = 100 + + network_configuration { + subnets = aws_subnet.private_app[*].id + security_groups = [aws_security_group.worker.id] + assign_public_ip = false + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-worker-service" + }) } \ No newline at end of file diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index 68c85ba9a..1d9753e70 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -91,4 +91,74 @@ output "redis_primary_endpoint_address" { output "redis_port" { description = "Port of the Apollo Redis replication group" value = aws_elasticache_replication_group.apollo.port +} + +output "ecr_repository_url" { + description = "URL of the Apollo ECR repository" + value = aws_ecr_repository.apollo.repository_url +} + +output "ecs_cluster_name" { + description = "Name of the Apollo ECS cluster" + value = aws_ecs_cluster.apollo.name +} + +output "ecs_cluster_arn" { + description = "ARN of the Apollo ECS cluster" + value = aws_ecs_cluster.apollo.arn +} + +output "ecs_task_execution_role_arn" { + description = "ARN of the ECS task execution role" + value = aws_iam_role.ecs_task_execution.arn +} + +output "apollo_task_role_arn" { + description = "ARN of the Apollo task role" + value = aws_iam_role.apollo_task.arn +} + +output "apollo_migration_task_definition_arn" { + description = "ARN of the Apollo migration task definition" + value = aws_ecs_task_definition.apollo_migration.arn +} + +output "apollo_web_task_definition_arn" { + description = "ARN of the Apollo web task definition" + value = aws_ecs_task_definition.apollo_web.arn +} + +output "apollo_worker_task_definition_arn" { + description = "ARN of the Apollo worker task definition" + value = aws_ecs_task_definition.apollo_worker.arn +} + +output "apollo_secret_key_secret_arn" { + description = "ARN of the Apollo SECRET_KEY secret" + value = aws_secretsmanager_secret.apollo_secret_key.arn +} + +output "apollo_db_password_secret_arn" { + description = "ARN of the Apollo database password secret" + value = aws_secretsmanager_secret.apollo_db_password.arn +} + +output "apollo_alb_dns_name" { + description = "DNS name of the Apollo load balancer" + value = aws_lb.apollo.dns_name +} + +output "apollo_alb_zone_id" { + description = "Route 53 zone ID of the Apollo load balancer" + value = aws_lb.apollo.zone_id +} + +output "apollo_web_service_name" { + description = "Name of the Apollo web ECS service" + value = aws_ecs_service.apollo_web.name +} + +output "apollo_worker_service_name" { + description = "Name of the Apollo worker ECS service" + value = aws_ecs_service.apollo_worker.name } \ No newline at end of file diff --git a/infra/terraform/terraform.tfvars b/infra/terraform/terraform.tfvars.example similarity index 50% rename from infra/terraform/terraform.tfvars rename to infra/terraform/terraform.tfvars.example index 48df67bff..f687dd244 100644 --- a/infra/terraform/terraform.tfvars +++ b/infra/terraform/terraform.tfvars.example @@ -1,4 +1,5 @@ aws_region = "us-east-1" project_name = "apollo" environment = "dev" -db_password = "hollowelkmine" +db_password = "agooddbpassword" +secret_key = "agoodsecretkeyforflask" diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index 51c078b98..2ee087ee2 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -104,4 +104,58 @@ variable "redis_port" { type = number description = "Port for Apollo Redis" default = 6379 +} + +variable "apollo_image_uri" { + type = string + description = "Apollo container image URI in ECR" + default = "592016371171.dkr.ecr.us-east-1.amazonaws.com/apollo:2026-03-30.1" +} + +variable "ecs_task_cpu" { + type = number + description = "CPU units for Apollo ECS tasks" + default = 512 +} + +variable "ecs_task_memory" { + type = number + description = "Memory (MiB) for Apollo ECS tasks" + default = 1024 +} + +variable "secret_key" { + type = string + description = "Flask secret key for Apollo" + sensitive = true +} + +variable "timezone" { + type = string + description = "Default timezone for Apollo" + default = "America/New_York" +} + +variable "default_email_sender" { + type = string + description = "Default email sender for Apollo" + default = "witness@cocitizen.com" +} + +variable "apollo_certificate_arn" { + type = string + description = "ACM certificate ARN for the Apollo public hostname" + default = "arn:aws:acm:us-east-1:592016371171:certificate/4e27f9a4-6087-4ac1-ab39-b76731d7a450" +} + +variable "apollo_hostname" { + type = string + description = "Public hostname for Apollo" + default = "witness.cocitizen.com" +} + +variable "health_check_path" { + type = string + description = "HTTP path used by the ALB target group health check" + default = "/" } \ No newline at end of file From b0add7911ae5628e802331fbb4a41dc89719b6fd Mon Sep 17 00:00:00 2001 From: Chris Doten Date: Thu, 2 Apr 2026 00:44:08 -0400 Subject: [PATCH 07/16] Ongoing major changes to Apollo Terraform scripts. However, this all appears to work --- infra/README.md | 64 +++++++++++-- infra/scripts/build-ecr.sh | 54 +++++++++++ infra/scripts/ecs-service-tasks.sh | 50 ++++++++++ infra/terraform/main.tf | 142 ++++++++++++++++++++++++++++- infra/terraform/outputs.tf | 25 +++++ infra/terraform/variables.tf | 20 +++- 6 files changed, 341 insertions(+), 14 deletions(-) create mode 100755 infra/scripts/build-ecr.sh create mode 100755 infra/scripts/ecs-service-tasks.sh diff --git a/infra/README.md b/infra/README.md index 6e8ea5a9f..9be98c2e4 100644 --- a/infra/README.md +++ b/infra/README.md @@ -4,10 +4,13 @@ This directory contains the Terraform configuration for Apollo's AWS deployment. Apollo is expected to run as a Flask/Gunicorn web service plus a separate Celery worker, backed by PostgreSQL/PostGIS, Redis, and S3 attachments. +The infrastructure now also includes the first ECS/ALB/Route 53 deployment path for bringing that runtime up in AWS. + ## Structure - `infra/bootstrap/` creates and manages the S3 bucket used for Terraform remote state. - `infra/terraform/` contains the main Terraform stack for Apollo infrastructure. +- `infra/scripts/` contains helper scripts for repeatable infrastructure/deployment tasks such as building and pushing ECS-compatible container images. ## Current architecture @@ -70,6 +73,25 @@ The worker service is not intended to receive direct inbound traffic. - Redis is in the private data subnets. - Redis is intended for Apollo's Celery/background-task queueing. +### Application runtime + +The current Terraform stack now includes the first ECS runtime layer for Apollo: + +- ECS cluster +- task execution role and task role +- Secrets Manager secrets for application runtime +- CloudWatch log groups +- ECS task definitions for migration, web, and worker +- ALB and listeners +- ECS services for web and worker +- Route 53 alias for the public hostname + +Apollo currently uses one Docker image with different commands for three roles: + +- **migration**: `flask db upgrade` +- **web**: `gunicorn -c gunicorn.py apollo.runner` +- **worker**: `celery --app=apollo.runner worker --beat --loglevel=WARNING --concurrency=2 --without-gossip --without-mingle --optimization=fair` + ## Design priorities This infrastructure is being built with the following priority order: @@ -98,6 +120,23 @@ Examples include: - single-AZ database deployment - secrets currently simple enough for bootstrapping rather than a final production secret-management pattern +## Deployment-specific configuration choices + +Some parts of this stack are reusable AWS/Apollo infrastructure patterns. Others are specific choices for this deployment and should be treated as configuration inputs rather than baked-in assumptions. + +Examples of deployment-specific choices currently include: + +- public hostname: `witness.cocitizen.com` +- default sender email: `witness@cocitizen.com` +- timezone: `America/New_York` +- ACM certificate for the public hostname +- Docker image tag/version used for ECS task definitions +- health check path used by the ALB +- runtime secrets such as the Flask `SECRET_KEY` and database password + +If this stack is reused for another Apollo deployment, these values are among the first things that should be reviewed and changed. + + ## Working with Terraform ### Bootstrap stack @@ -126,6 +165,12 @@ terraform plan terraform apply ``` +### Build and push helper + +A helper script for building and pushing ECS-compatible container images lives under `infra/scripts/`. + +Because local development may happen on Apple Silicon hardware while ECS is running x86_64 workloads, images intended for ECS should be built for `linux/amd64`. + ## Notes on state - Terraform state for the main stack is stored remotely in S3. @@ -133,17 +178,18 @@ terraform apply - `.terraform.lock.hcl` should be committed. - local `*.tfstate` files should not be committed. -## Near-term expected additions +## Near-term expected work -The current stack is not yet complete. Likely next pieces include: +The current stack now includes the first ECS runtime layer, but Apollo is not yet fully proven in this environment. -- a one-off application migration task during deployment -- ECR repository -- ECS services for web and worker -- ALB -- certificate and DNS wiring -- ECS task definitions and runtime configuration for web, worker, and migration -- confirmation that Apollo migrations enable PostGIS cleanly in the AWS environment +Likely next work includes: + +- running the one-off migration task successfully in ECS +- confirming that Apollo migrations enable PostGIS cleanly in AWS +- verifying that the web service comes healthy behind the ALB +- verifying that the worker service starts and remains healthy +- deciding whether ECS tasks should remain in public subnets for bring-up or move back to private app subnets with NAT or VPC endpoints +- tightening secret handling and other dev-stage compromises before treating the deployment as production-ready ## Intent of the split between `bootstrap` and `terraform` diff --git a/infra/scripts/build-ecr.sh b/infra/scripts/build-ecr.sh new file mode 100755 index 000000000..18c3ec2a3 --- /dev/null +++ b/infra/scripts/build-ecr.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# +# Build Apollo's production Docker image for ECS/Fargate on AWS. +# +# Important: +# - Forces linux/amd64 so builds from Apple Silicon Macs still run on ECS. +# - Builds the production target from the multi-stage Dockerfile. +# - Tags both the local image and the ECR image. +# +# Usage: +# ./scripts/build-ecr.sh 2026-03-31.2 +# +# Optional env vars: +# AWS_REGION=us-east-1 +# AWS_ACCOUNT_ID=592016371171 +# ECR_REPOSITORY=apollo + +set -euo pipefail + +TAG="${1:-}" + +if [[ -z "$TAG" ]]; then + echo "Usage: $0 " + exit 1 +fi + +AWS_REGION="${AWS_REGION:-us-east-1}" +AWS_ACCOUNT_ID="${AWS_ACCOUNT_ID:-592016371171}" +ECR_REPOSITORY="${ECR_REPOSITORY:-apollo}" + +LOCAL_IMAGE="${ECR_REPOSITORY}:${TAG}" +ECR_IMAGE="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY}:${TAG}" + +echo "Building ${LOCAL_IMAGE} for linux/amd64..." +docker buildx build \ + --platform linux/amd64 \ + --target production \ + -t "${LOCAL_IMAGE}" \ + . + +echo "Logging into ECR..." +aws ecr get-login-password --region "${AWS_REGION}" \ + | docker login --username AWS --password-stdin "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + +echo "Tagging image as ${ECR_IMAGE}..." +docker tag "${LOCAL_IMAGE}" "${ECR_IMAGE}" + +echo "Pushing ${ECR_IMAGE}..." +docker push "${ECR_IMAGE}" + +echo +echo "Done." +echo "Image URI:" +echo "${ECR_IMAGE}" \ No newline at end of file diff --git a/infra/scripts/ecs-service-tasks.sh b/infra/scripts/ecs-service-tasks.sh new file mode 100755 index 000000000..22ab8d5c0 --- /dev/null +++ b/infra/scripts/ecs-service-tasks.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# +# Show the current ECS tasks for a service and summarize their status. +# +# Usage: +# ./infra/scripts/ecs-service-tasks.sh apollo-dev apollo-dev-web +# +# Optional env vars: +# AWS_REGION=us-east-1 + +set -euo pipefail + +CLUSTER="${1:-}" +SERVICE="${2:-}" +AWS_REGION="${AWS_REGION:-us-east-1}" + +if [[ -z "$CLUSTER" || -z "$SERVICE" ]]; then + echo "Usage: $0 " + exit 1 +fi + +TASK_ARNS=$(aws ecs list-tasks \ + --cluster "$CLUSTER" \ + --service-name "$SERVICE" \ + --region "$AWS_REGION" \ + --query 'taskArns' \ + --output text) + +if [[ -z "$TASK_ARNS" ]]; then + echo "No tasks found for service '$SERVICE' in cluster '$CLUSTER'." + exit 0 +fi + +aws ecs describe-tasks \ + --cluster "$CLUSTER" \ + --tasks $TASK_ARNS \ + --region "$AWS_REGION" \ + --query 'tasks[*].{ + taskArn: taskArn, + lastStatus: lastStatus, + desiredStatus: desiredStatus, + stopCode: stopCode, + stoppedReason: stoppedReason, + containers: containers[*].{ + name: name, + lastStatus: lastStatus, + reason: reason, + exitCode: exitCode + } + }' \ No newline at end of file diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 46ee162c0..7c4ccfeae 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -513,6 +513,14 @@ resource "aws_ecs_task_definition" "apollo_migration" { { name = "DATABASE_PASSWORD" valueFrom = aws_secretsmanager_secret_version.apollo_db_password.arn + }, + { + name = "AWS_ACCESS_KEY_ID" + valueFrom = aws_secretsmanager_secret_version.apollo_aws_access_key_id.arn + }, + { + name = "AWS_SECRET_ACCESS_KEY" + valueFrom = aws_secretsmanager_secret_version.apollo_aws_secret_access_key.arn } ] @@ -566,6 +574,14 @@ resource "aws_ecs_task_definition" "apollo_web" { { name = "DATABASE_PASSWORD" valueFrom = aws_secretsmanager_secret_version.apollo_db_password.arn + }, + { + name = "AWS_ACCESS_KEY_ID" + valueFrom = aws_secretsmanager_secret_version.apollo_aws_access_key_id.arn + }, + { + name = "AWS_SECRET_ACCESS_KEY" + valueFrom = aws_secretsmanager_secret_version.apollo_aws_secret_access_key.arn } ] @@ -611,6 +627,14 @@ resource "aws_ecs_task_definition" "apollo_worker" { { name = "DATABASE_PASSWORD" valueFrom = aws_secretsmanager_secret_version.apollo_db_password.arn + }, + { + name = "AWS_ACCESS_KEY_ID" + valueFrom = aws_secretsmanager_secret_version.apollo_aws_access_key_id.arn + }, + { + name = "AWS_SECRET_ACCESS_KEY" + valueFrom = aws_secretsmanager_secret_version.apollo_aws_secret_access_key.arn } ] @@ -732,9 +756,9 @@ resource "aws_ecs_service" "apollo_web" { deployment_maximum_percent = 200 network_configuration { - subnets = aws_subnet.private_app[*].id + subnets = aws_subnet.public[*].id security_groups = [aws_security_group.web.id] - assign_public_ip = false + assign_public_ip = true } load_balancer { @@ -763,12 +787,122 @@ resource "aws_ecs_service" "apollo_worker" { deployment_maximum_percent = 100 network_configuration { - subnets = aws_subnet.private_app[*].id + subnets = aws_subnet.public[*].id security_groups = [aws_security_group.worker.id] - assign_public_ip = false + assign_public_ip = true } tags = merge(local.common_tags, { Name = "${local.name_prefix}-worker-service" }) +} + +data "aws_route53_zone" "cocitizen" { + name = "cocitizen.com" + private_zone = false +} + +resource "aws_route53_record" "apollo" { + zone_id = data.aws_route53_zone.cocitizen.zone_id + name = var.apollo_hostname + type = "A" + + alias { + name = aws_lb.apollo.dns_name + zone_id = aws_lb.apollo.zone_id + evaluate_target_health = true + } +} + +data "aws_iam_policy_document" "ecs_task_execution_secrets" { + statement { + effect = "Allow" + actions = [ + "secretsmanager:GetSecretValue" + ] + resources = [ + aws_secretsmanager_secret.apollo_secret_key.arn, + aws_secretsmanager_secret.apollo_db_password.arn, + aws_secretsmanager_secret.apollo_aws_access_key_id.arn, + aws_secretsmanager_secret.apollo_aws_secret_access_key.arn + ] + } +} + +resource "aws_iam_role_policy" "ecs_task_execution_secrets" { + name = "${local.name_prefix}-ecs-task-execution-secrets" + role = aws_iam_role.ecs_task_execution.id + policy = data.aws_iam_policy_document.ecs_task_execution_secrets.json +} + +resource "aws_secretsmanager_secret" "apollo_aws_access_key_id" { + name = "${local.name_prefix}/apollo/aws-access-key-id" + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-apollo-aws-access-key-id" + }) +} + +resource "aws_secretsmanager_secret_version" "apollo_aws_access_key_id" { + secret_id = aws_secretsmanager_secret.apollo_aws_access_key_id.id + secret_string = var.aws_access_key_id +} + +resource "aws_secretsmanager_secret" "apollo_aws_secret_access_key" { + name = "${local.name_prefix}/apollo/aws-secret-access-key" + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-apollo-aws-secret-access-key" + }) +} + +resource "aws_secretsmanager_secret_version" "apollo_aws_secret_access_key" { + secret_id = aws_secretsmanager_secret.apollo_aws_secret_access_key.id + secret_string = var.aws_secret_access_key +} + +resource "aws_iam_user" "apollo_s3" { + name = var.apollo_s3_iam_username + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-apollo-s3-user" + }) +} + +data "aws_iam_policy_document" "apollo_s3_user_policy" { + statement { + effect = "Allow" + actions = [ + "s3:ListAllMyBuckets" + ] + resources = ["*"] + } + + statement { + effect = "Allow" + actions = [ + "s3:ListBucket" + ] + resources = [ + aws_s3_bucket.apollo_attachments.arn + ] + } + + statement { + effect = "Allow" + actions = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ] + resources = [ + "${aws_s3_bucket.apollo_attachments.arn}/*" + ] + } +} + +resource "aws_iam_user_policy" "apollo_s3_user_policy" { + name = "${local.name_prefix}-apollo-s3-user-policy" + user = aws_iam_user.apollo_s3.name + policy = data.aws_iam_policy_document.apollo_s3_user_policy.json } \ No newline at end of file diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index 1d9753e70..db1d96d3f 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -161,4 +161,29 @@ output "apollo_web_service_name" { output "apollo_worker_service_name" { description = "Name of the Apollo worker ECS service" value = aws_ecs_service.apollo_worker.name +} + +output "apollo_public_hostname" { + description = "Public hostname for Apollo" + value = aws_route53_record.apollo.fqdn +} + +output "apollo_aws_access_key_id_secret_arn" { + description = "ARN of the Apollo AWS access key ID secret" + value = aws_secretsmanager_secret.apollo_aws_access_key_id.arn +} + +output "apollo_aws_secret_access_key_secret_arn" { + description = "ARN of the Apollo AWS secret access key secret" + value = aws_secretsmanager_secret.apollo_aws_secret_access_key.arn +} + +output "apollo_s3_iam_username" { + description = "IAM username for Apollo S3 attachment access" + value = aws_iam_user.apollo_s3.name +} + +output "apollo_s3_iam_user_arn" { + description = "ARN of the Apollo S3 IAM user" + value = aws_iam_user.apollo_s3.arn } \ No newline at end of file diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index 2ee087ee2..58dfb64b5 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -109,7 +109,7 @@ variable "redis_port" { variable "apollo_image_uri" { type = string description = "Apollo container image URI in ECR" - default = "592016371171.dkr.ecr.us-east-1.amazonaws.com/apollo:2026-03-30.1" + default = "592016371171.dkr.ecr.us-east-1.amazonaws.com/apollo:2026-03-31.2" } variable "ecs_task_cpu" { @@ -158,4 +158,22 @@ variable "health_check_path" { type = string description = "HTTP path used by the ALB target group health check" default = "/" +} + +variable "aws_access_key_id" { + type = string + description = "AWS access key ID used by Apollo for S3 attachments" + sensitive = true +} + +variable "aws_secret_access_key" { + type = string + description = "AWS secret access key used by Apollo for S3 attachments" + sensitive = true +} + +variable "apollo_s3_iam_username" { + type = string + description = "IAM username for Apollo's S3 attachment access" + default = "apollo-s3" } \ No newline at end of file From 8f7786d561cfd4aeccff201506e945e29e362c1d Mon Sep 17 00:00:00 2001 From: Chris Doten Date: Thu, 2 Apr 2026 11:14:41 -0400 Subject: [PATCH 08/16] Refactoring Terraform config files to make it more clear, including splitting deployment and foundational elements from variables and outputs. --- infra/terraform/database.tf | 43 + infra/terraform/dns-lb.tf | 85 ++ infra/terraform/ecr.tf | 15 + infra/terraform/ecs.tf | 314 ++++++ infra/terraform/iam.tf | 136 +++ infra/terraform/main.tf | 904 +----------------- infra/terraform/network.tf | 83 ++ infra/terraform/outputs-deployment.tf | 53 + .../{outputs.tf => outputs-foundation.tf} | 55 +- infra/terraform/redis.tf | 35 + infra/terraform/secrets.tf | 55 ++ infra/terraform/security.tf | 142 +++ infra/terraform/storage.tf | 18 + infra/terraform/variables-deployment.tf | 75 ++ .../{variables.tf => variables-foundation.tf} | 88 +- 15 files changed, 1081 insertions(+), 1020 deletions(-) create mode 100644 infra/terraform/database.tf create mode 100644 infra/terraform/dns-lb.tf create mode 100644 infra/terraform/ecr.tf create mode 100644 infra/terraform/ecs.tf create mode 100644 infra/terraform/iam.tf create mode 100644 infra/terraform/network.tf create mode 100644 infra/terraform/outputs-deployment.tf rename infra/terraform/{outputs.tf => outputs-foundation.tf} (69%) create mode 100644 infra/terraform/redis.tf create mode 100644 infra/terraform/secrets.tf create mode 100644 infra/terraform/security.tf create mode 100644 infra/terraform/storage.tf create mode 100644 infra/terraform/variables-deployment.tf rename infra/terraform/{variables.tf => variables-foundation.tf} (59%) diff --git a/infra/terraform/database.tf b/infra/terraform/database.tf new file mode 100644 index 000000000..37a16c54b --- /dev/null +++ b/infra/terraform/database.tf @@ -0,0 +1,43 @@ +######################################## +# Database +######################################## + +resource "aws_db_subnet_group" "apollo" { + name = "${local.name_prefix}-db-subnet-group" + subnet_ids = aws_subnet.private_data[*].id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-db-subnet-group" + }) +} + +resource "aws_db_instance" "apollo" { + identifier = "${local.name_prefix}-postgres" + + engine = "postgres" + engine_version = var.db_engine_version + instance_class = var.db_instance_class + + allocated_storage = var.db_allocated_storage + storage_type = "gp3" + db_name = var.db_name + username = var.db_username + password = var.db_password + port = 5432 + + db_subnet_group_name = aws_db_subnet_group.apollo.name + vpc_security_group_ids = [aws_security_group.rds.id] + + publicly_accessible = false + multi_az = false + + skip_final_snapshot = true + deletion_protection = false + + backup_retention_period = 7 + auto_minor_version_upgrade = true + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-postgres" + }) +} \ No newline at end of file diff --git a/infra/terraform/dns-lb.tf b/infra/terraform/dns-lb.tf new file mode 100644 index 000000000..7b234633a --- /dev/null +++ b/infra/terraform/dns-lb.tf @@ -0,0 +1,85 @@ +######################################## +# DNS / Load Balancing +######################################## + +resource "aws_lb" "apollo" { + name = "${local.name_prefix}-alb" + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.alb.id] + subnets = aws_subnet.public[*].id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-alb" + }) +} + +resource "aws_lb_target_group" "apollo_web" { + name = "${local.name_prefix}-web-tg" + port = var.app_port + protocol = "HTTP" + target_type = "ip" + vpc_id = aws_vpc.apollo.id + + health_check { + enabled = true + path = var.health_check_path + port = "traffic-port" + protocol = "HTTP" + matcher = "200-399" + healthy_threshold = 2 + unhealthy_threshold = 5 + timeout = 5 + interval = 30 + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-web-tg" + }) +} + +resource "aws_lb_listener" "apollo_https" { + load_balancer_arn = aws_lb.apollo.arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-2016-08" + certificate_arn = var.apollo_certificate_arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.apollo_web.arn + } +} + +resource "aws_lb_listener" "apollo_http_redirect" { + load_balancer_arn = aws_lb.apollo.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "redirect" + + redirect { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } +} + +data "aws_route53_zone" "cocitizen" { + name = "cocitizen.com" + private_zone = false +} + +resource "aws_route53_record" "apollo" { + zone_id = data.aws_route53_zone.cocitizen.zone_id + name = var.apollo_hostname + type = "A" + + alias { + name = aws_lb.apollo.dns_name + zone_id = aws_lb.apollo.zone_id + evaluate_target_health = true + } +} \ No newline at end of file diff --git a/infra/terraform/ecr.tf b/infra/terraform/ecr.tf new file mode 100644 index 000000000..0360900a9 --- /dev/null +++ b/infra/terraform/ecr.tf @@ -0,0 +1,15 @@ +######################################## +# ECR +######################################## + +resource "aws_ecr_repository" "apollo" { + name = "apollo" + + image_scanning_configuration { + scan_on_push = true + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-ecr" + }) +} \ No newline at end of file diff --git a/infra/terraform/ecs.tf b/infra/terraform/ecs.tf new file mode 100644 index 000000000..91b0200c6 --- /dev/null +++ b/infra/terraform/ecs.tf @@ -0,0 +1,314 @@ +######################################## +# ECS +######################################## + +resource "aws_ecs_cluster" "apollo" { + name = local.name_prefix + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-ecs-cluster" + }) +} + +resource "aws_cloudwatch_log_group" "apollo_migration" { + name = "/ecs/${local.name_prefix}-migration" + retention_in_days = 14 + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-migration-logs" + }) +} + +resource "aws_cloudwatch_log_group" "apollo_web" { + name = "/ecs/${local.name_prefix}-web" + retention_in_days = 14 + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-web-logs" + }) +} + +resource "aws_cloudwatch_log_group" "apollo_worker" { + name = "/ecs/${local.name_prefix}-worker" + retention_in_days = 14 + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-worker-logs" + }) +} + +locals { + apollo_common_environment = [ + { + name = "PREFERRED_URL_SCHEME" + value = "https" + }, + { + name = "DATABASE_HOSTNAME" + value = aws_db_instance.apollo.address + }, + { + name = "DATABASE_NAME" + value = aws_db_instance.apollo.db_name + }, + { + name = "DATABASE_USERNAME" + value = var.db_username + }, + { + name = "REDIS_HOSTNAME" + value = aws_elasticache_replication_group.apollo.primary_endpoint_address + }, + { + name = "REDIS_DATABASE" + value = "0" + }, + { + name = "ATTACHMENTS_USE_S3" + value = "true" + }, + { + name = "AWS_DEFAULT_BUCKET" + value = aws_s3_bucket.apollo_attachments.bucket + }, + { + name = "AWS_DEFAULT_REGION" + value = var.aws_region + }, + { + name = "TIMEZONE" + value = var.timezone + }, + { + name = "DEFAULT_EMAIL_SENDER" + value = var.default_email_sender + }, + { + name = "FLASK_ENV" + value = "production" + }, + { + name = "FLASK_APP" + value = "apollo.runner" + } + ] +} + +resource "aws_ecs_task_definition" "apollo_migration" { + family = "${local.name_prefix}-migration" + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = tostring(var.ecs_task_cpu) + memory = tostring(var.ecs_task_memory) + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.apollo_task.arn + + container_definitions = jsonencode([ + { + name = "apollo-migration" + image = var.apollo_image_uri + essential = true + command = ["flask", "db", "upgrade"] + + environment = local.apollo_common_environment + + secrets = [ + { + name = "SECRET_KEY" + valueFrom = aws_secretsmanager_secret_version.apollo_secret_key.arn + }, + { + name = "DATABASE_PASSWORD" + valueFrom = aws_secretsmanager_secret_version.apollo_db_password.arn + }, + { + name = "AWS_ACCESS_KEY_ID" + valueFrom = aws_secretsmanager_secret_version.apollo_aws_access_key_id.arn + }, + { + name = "AWS_SECRET_ACCESS_KEY" + valueFrom = aws_secretsmanager_secret_version.apollo_aws_secret_access_key.arn + } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.apollo_migration.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "ecs" + } + } + } + ]) + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-migration-taskdef" + }) +} + +resource "aws_ecs_task_definition" "apollo_web" { + family = "${local.name_prefix}-web" + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = tostring(var.ecs_task_cpu) + memory = tostring(var.ecs_task_memory) + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.apollo_task.arn + + container_definitions = jsonencode([ + { + name = "apollo-web" + image = var.apollo_image_uri + essential = true + command = ["gunicorn", "-c", "gunicorn.py", "apollo.runner"] + + portMappings = [ + { + containerPort = var.app_port + hostPort = var.app_port + protocol = "tcp" + } + ] + + environment = local.apollo_common_environment + + secrets = [ + { + name = "SECRET_KEY" + valueFrom = aws_secretsmanager_secret_version.apollo_secret_key.arn + }, + { + name = "DATABASE_PASSWORD" + valueFrom = aws_secretsmanager_secret_version.apollo_db_password.arn + }, + { + name = "AWS_ACCESS_KEY_ID" + valueFrom = aws_secretsmanager_secret_version.apollo_aws_access_key_id.arn + }, + { + name = "AWS_SECRET_ACCESS_KEY" + valueFrom = aws_secretsmanager_secret_version.apollo_aws_secret_access_key.arn + } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.apollo_web.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "ecs" + } + } + } + ]) + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-web-taskdef" + }) +} + +resource "aws_ecs_task_definition" "apollo_worker" { + family = "${local.name_prefix}-worker" + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = tostring(var.ecs_task_cpu) + memory = tostring(var.ecs_task_memory) + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.apollo_task.arn + + container_definitions = jsonencode([ + { + name = "apollo-worker" + image = var.apollo_image_uri + essential = true + command = ["celery", "--app=apollo.runner", "worker", "--beat", "--loglevel=WARNING", "--concurrency=2", "--without-gossip", "--without-mingle", "--optimization=fair"] + + environment = local.apollo_common_environment + + secrets = [ + { + name = "SECRET_KEY" + valueFrom = aws_secretsmanager_secret_version.apollo_secret_key.arn + }, + { + name = "DATABASE_PASSWORD" + valueFrom = aws_secretsmanager_secret_version.apollo_db_password.arn + }, + { + name = "AWS_ACCESS_KEY_ID" + valueFrom = aws_secretsmanager_secret_version.apollo_aws_access_key_id.arn + }, + { + name = "AWS_SECRET_ACCESS_KEY" + valueFrom = aws_secretsmanager_secret_version.apollo_aws_secret_access_key.arn + } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.apollo_worker.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "ecs" + } + } + } + ]) + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-worker-taskdef" + }) +} + +resource "aws_ecs_service" "apollo_web" { + name = "${local.name_prefix}-web" + cluster = aws_ecs_cluster.apollo.id + task_definition = aws_ecs_task_definition.apollo_web.arn + desired_count = 1 + launch_type = "FARGATE" + + deployment_minimum_healthy_percent = 50 + deployment_maximum_percent = 200 + + network_configuration { + subnets = aws_subnet.public[*].id + security_groups = [aws_security_group.web.id] + assign_public_ip = true + } + + load_balancer { + target_group_arn = aws_lb_target_group.apollo_web.arn + container_name = "apollo-web" + container_port = var.app_port + } + + depends_on = [ + aws_lb_listener.apollo_https + ] + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-web-service" + }) +} + +resource "aws_ecs_service" "apollo_worker" { + name = "${local.name_prefix}-worker" + cluster = aws_ecs_cluster.apollo.id + task_definition = aws_ecs_task_definition.apollo_worker.arn + desired_count = 1 + launch_type = "FARGATE" + + deployment_minimum_healthy_percent = 0 + deployment_maximum_percent = 100 + + network_configuration { + subnets = aws_subnet.public[*].id + security_groups = [aws_security_group.worker.id] + assign_public_ip = true + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-worker-service" + }) +} diff --git a/infra/terraform/iam.tf b/infra/terraform/iam.tf new file mode 100644 index 000000000..1ad556620 --- /dev/null +++ b/infra/terraform/iam.tf @@ -0,0 +1,136 @@ +######################################## +# IAM +######################################## + +data "aws_iam_policy_document" "ecs_tasks_assume_role" { + statement { + effect = "Allow" + + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + + actions = ["sts:AssumeRole"] + } +} + +resource "aws_iam_role" "ecs_task_execution" { + name = "${local.name_prefix}-ecs-task-execution-role" + assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume_role.json + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-ecs-task-execution-role" + }) +} + +resource "aws_iam_role_policy_attachment" "ecs_task_execution_managed" { + role = aws_iam_role.ecs_task_execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role" "apollo_task" { + name = "${local.name_prefix}-apollo-task-role" + assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume_role.json + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-apollo-task-role" + }) +} + +data "aws_iam_policy_document" "apollo_task_s3" { + statement { + effect = "Allow" + actions = [ + "s3:ListBucket" + ] + resources = [ + aws_s3_bucket.apollo_attachments.arn + ] + } + + statement { + effect = "Allow" + actions = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ] + resources = [ + "${aws_s3_bucket.apollo_attachments.arn}/*" + ] + } +} + +resource "aws_iam_role_policy" "apollo_task_s3" { + name = "${local.name_prefix}-apollo-task-s3" + role = aws_iam_role.apollo_task.id + policy = data.aws_iam_policy_document.apollo_task_s3.json +} + +data "aws_iam_policy_document" "ecs_task_execution_secrets" { + statement { + effect = "Allow" + actions = [ + "secretsmanager:GetSecretValue" + ] + resources = [ + aws_secretsmanager_secret.apollo_secret_key.arn, + aws_secretsmanager_secret.apollo_db_password.arn, + aws_secretsmanager_secret.apollo_aws_access_key_id.arn, + aws_secretsmanager_secret.apollo_aws_secret_access_key.arn + ] + } +} + +resource "aws_iam_role_policy" "ecs_task_execution_secrets" { + name = "${local.name_prefix}-ecs-task-execution-secrets" + role = aws_iam_role.ecs_task_execution.id + policy = data.aws_iam_policy_document.ecs_task_execution_secrets.json +} + +resource "aws_iam_user" "apollo_s3" { + name = var.apollo_s3_iam_username + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-apollo-s3-user" + }) +} + +data "aws_iam_policy_document" "apollo_s3_user_policy" { + statement { + effect = "Allow" + actions = [ + "s3:ListAllMyBuckets" + ] + resources = ["*"] + } + + statement { + effect = "Allow" + actions = [ + "s3:ListBucket" + ] + resources = [ + aws_s3_bucket.apollo_attachments.arn + ] + } + + statement { + effect = "Allow" + actions = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ] + resources = [ + "${aws_s3_bucket.apollo_attachments.arn}/*" + ] + } +} + +resource "aws_iam_user_policy" "apollo_s3_user_policy" { + name = "${local.name_prefix}-apollo-s3-user-policy" + user = aws_iam_user.apollo_s3.name + policy = data.aws_iam_policy_document.apollo_s3_user_policy.json +} diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 7c4ccfeae..a4b8cee2a 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -1,3 +1,8 @@ +######################################## +# Shared locals +# Naming and tagging tools used across this root +######################################## + locals { name_prefix = "${var.project_name}-${var.environment}" @@ -5,904 +10,7 @@ locals { Project = var.project_name Environment = var.environment ManagedBy = "Terraform" - Owner = "cdoten" - } -} -resource "aws_s3_bucket" "apollo_attachments" { - bucket = "cdoten-apollo-dev-attachments" - - tags = local.common_tags -} - -resource "aws_s3_bucket_public_access_block" "apollo_attachments" { - bucket = aws_s3_bucket.apollo_attachments.id - - block_public_acls = true - block_public_policy = true - ignore_public_acls = true - restrict_public_buckets = true -} - -resource "aws_vpc" "apollo" { - cidr_block = var.vpc_cidr - enable_dns_support = true - enable_dns_hostnames = true - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-vpc" - }) -} - -resource "aws_subnet" "public" { - count = length(var.public_subnet_cidrs) - - vpc_id = aws_vpc.apollo.id - cidr_block = var.public_subnet_cidrs[count.index] - availability_zone = var.availability_zones[count.index] - map_public_ip_on_launch = true - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-public-${count.index + 1}" - Tier = "public" - }) -} - -resource "aws_subnet" "private_app" { - count = length(var.private_app_subnet_cidrs) - - vpc_id = aws_vpc.apollo.id - cidr_block = var.private_app_subnet_cidrs[count.index] - availability_zone = var.availability_zones[count.index] - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-private-app-${count.index + 1}" - Tier = "private-app" - }) -} - -resource "aws_subnet" "private_data" { - count = length(var.private_data_subnet_cidrs) - - vpc_id = aws_vpc.apollo.id - cidr_block = var.private_data_subnet_cidrs[count.index] - availability_zone = var.availability_zones[count.index] - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-private-data-${count.index + 1}" - Tier = "private-data" - }) -} - -resource "aws_internet_gateway" "apollo" { - vpc_id = aws_vpc.apollo.id - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-igw" - }) -} - -resource "aws_route_table" "public" { - vpc_id = aws_vpc.apollo.id - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-public-rt" - Tier = "public" - }) -} - -resource "aws_route" "public_internet_access" { - route_table_id = aws_route_table.public.id - destination_cidr_block = "0.0.0.0/0" - gateway_id = aws_internet_gateway.apollo.id -} - -resource "aws_route_table_association" "public" { - count = length(aws_subnet.public) - - subnet_id = aws_subnet.public[count.index].id - route_table_id = aws_route_table.public.id -} - - -resource "aws_security_group" "alb" { - name = "${local.name_prefix}-alb-sg" - description = "Security group for the Apollo load balancer" - vpc_id = aws_vpc.apollo.id - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-alb-sg" - }) -} - -resource "aws_vpc_security_group_ingress_rule" "alb_https_in" { - security_group_id = aws_security_group.alb.id - cidr_ipv4 = "0.0.0.0/0" - from_port = 443 - to_port = 443 - ip_protocol = "tcp" - description = "Allow HTTPS from the internet" -} - -resource "aws_vpc_security_group_egress_rule" "alb_all_out" { - security_group_id = aws_security_group.alb.id - cidr_ipv4 = "0.0.0.0/0" - ip_protocol = "-1" - description = "Allow all outbound traffic" -} - -resource "aws_security_group" "web" { - name = "${local.name_prefix}-web-sg" - description = "Security group for Apollo web tasks" - vpc_id = aws_vpc.apollo.id - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-web-sg" - }) -} - -resource "aws_vpc_security_group_ingress_rule" "web_from_alb" { - security_group_id = aws_security_group.web.id - referenced_security_group_id = aws_security_group.alb.id - from_port = var.app_port - to_port = var.app_port - ip_protocol = "tcp" - description = "Allow app traffic from the ALB" -} - -resource "aws_vpc_security_group_egress_rule" "web_all_out" { - security_group_id = aws_security_group.web.id - cidr_ipv4 = "0.0.0.0/0" - ip_protocol = "-1" - description = "Allow all outbound traffic" -} - -resource "aws_security_group" "worker" { - name = "${local.name_prefix}-worker-sg" - description = "Security group for Apollo worker tasks" - vpc_id = aws_vpc.apollo.id - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-worker-sg" - }) -} - -resource "aws_vpc_security_group_egress_rule" "worker_all_out" { - security_group_id = aws_security_group.worker.id - cidr_ipv4 = "0.0.0.0/0" - ip_protocol = "-1" - description = "Allow all outbound traffic" -} - -resource "aws_security_group" "rds" { - name = "${local.name_prefix}-rds-sg" - description = "Security group for Apollo PostgreSQL" - vpc_id = aws_vpc.apollo.id - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-rds-sg" - }) -} - -resource "aws_vpc_security_group_ingress_rule" "rds_from_web" { - security_group_id = aws_security_group.rds.id - referenced_security_group_id = aws_security_group.web.id - from_port = 5432 - to_port = 5432 - ip_protocol = "tcp" - description = "Allow PostgreSQL from web tasks" -} - -resource "aws_vpc_security_group_ingress_rule" "rds_from_worker" { - security_group_id = aws_security_group.rds.id - referenced_security_group_id = aws_security_group.worker.id - from_port = 5432 - to_port = 5432 - ip_protocol = "tcp" - description = "Allow PostgreSQL from worker tasks" -} - -resource "aws_vpc_security_group_egress_rule" "rds_all_out" { - security_group_id = aws_security_group.rds.id - cidr_ipv4 = "0.0.0.0/0" - ip_protocol = "-1" - description = "Allow all outbound traffic" -} - -resource "aws_security_group" "redis" { - name = "${local.name_prefix}-redis-sg" - description = "Security group for Apollo Redis" - vpc_id = aws_vpc.apollo.id - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-redis-sg" - }) -} - -resource "aws_vpc_security_group_ingress_rule" "redis_from_web" { - security_group_id = aws_security_group.redis.id - referenced_security_group_id = aws_security_group.web.id - from_port = 6379 - to_port = 6379 - ip_protocol = "tcp" - description = "Allow Redis from web tasks" -} - -resource "aws_vpc_security_group_ingress_rule" "redis_from_worker" { - security_group_id = aws_security_group.redis.id - referenced_security_group_id = aws_security_group.worker.id - from_port = 6379 - to_port = 6379 - ip_protocol = "tcp" - description = "Allow Redis from worker tasks" -} - -resource "aws_vpc_security_group_egress_rule" "redis_all_out" { - security_group_id = aws_security_group.redis.id - cidr_ipv4 = "0.0.0.0/0" - ip_protocol = "-1" - description = "Allow all outbound traffic" -} - -resource "aws_db_subnet_group" "apollo" { - name = "${local.name_prefix}-db-subnet-group" - subnet_ids = aws_subnet.private_data[*].id - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-db-subnet-group" - }) -} - -resource "aws_db_instance" "apollo" { - identifier = "${local.name_prefix}-postgres" - - engine = "postgres" - engine_version = var.db_engine_version - instance_class = var.db_instance_class - - allocated_storage = var.db_allocated_storage - storage_type = "gp3" - db_name = var.db_name - username = var.db_username - password = var.db_password - port = 5432 - - db_subnet_group_name = aws_db_subnet_group.apollo.name - vpc_security_group_ids = [aws_security_group.rds.id] - - publicly_accessible = false - multi_az = false - - skip_final_snapshot = true - deletion_protection = false - - backup_retention_period = 7 - - auto_minor_version_upgrade = true - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-postgres" - }) -} - -resource "aws_elasticache_subnet_group" "apollo" { - name = "${local.name_prefix}-redis-subnet-group" - subnet_ids = aws_subnet.private_data[*].id - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-redis-subnet-group" - }) -} - -resource "aws_elasticache_replication_group" "apollo" { - replication_group_id = "${local.name_prefix}-redis" - description = "Apollo Redis replication group" - engine = "redis" - engine_version = var.redis_engine_version - node_type = var.redis_node_type - port = var.redis_port - - num_cache_clusters = 1 - automatic_failover_enabled = false - multi_az_enabled = false - - subnet_group_name = aws_elasticache_subnet_group.apollo.name - security_group_ids = [aws_security_group.redis.id] - - at_rest_encryption_enabled = true - transit_encryption_enabled = false - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-redis" - }) -} - -resource "aws_ecr_repository" "apollo" { - name = "apollo" - - image_scanning_configuration { - scan_on_push = true - } - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-ecr" - }) -} - -resource "aws_ecs_cluster" "apollo" { - name = local.name_prefix - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-ecs-cluster" - }) -} - - -data "aws_iam_policy_document" "ecs_tasks_assume_role" { - statement { - effect = "Allow" - - principals { - type = "Service" - identifiers = ["ecs-tasks.amazonaws.com"] - } - - actions = ["sts:AssumeRole"] - } -} - -resource "aws_iam_role" "ecs_task_execution" { - name = "${local.name_prefix}-ecs-task-execution-role" - assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume_role.json - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-ecs-task-execution-role" - }) -} - -resource "aws_iam_role_policy_attachment" "ecs_task_execution_managed" { - role = aws_iam_role.ecs_task_execution.name - policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" -} - -resource "aws_iam_role" "apollo_task" { - name = "${local.name_prefix}-apollo-task-role" - assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume_role.json - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-apollo-task-role" - }) -} - -data "aws_iam_policy_document" "apollo_task_s3" { - statement { - effect = "Allow" - actions = [ - "s3:ListBucket" - ] - resources = [ - aws_s3_bucket.apollo_attachments.arn - ] - } - - statement { - effect = "Allow" - actions = [ - "s3:GetObject", - "s3:PutObject", - "s3:DeleteObject" - ] - resources = [ - "${aws_s3_bucket.apollo_attachments.arn}/*" - ] - } -} - -resource "aws_iam_role_policy" "apollo_task_s3" { - name = "${local.name_prefix}-apollo-task-s3" - role = aws_iam_role.apollo_task.id - policy = data.aws_iam_policy_document.apollo_task_s3.json -} - -resource "aws_cloudwatch_log_group" "apollo_migration" { - name = "/ecs/${local.name_prefix}-migration" - retention_in_days = 14 - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-migration-logs" - }) -} - -resource "aws_cloudwatch_log_group" "apollo_web" { - name = "/ecs/${local.name_prefix}-web" - retention_in_days = 14 - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-web-logs" - }) -} - -resource "aws_cloudwatch_log_group" "apollo_worker" { - name = "/ecs/${local.name_prefix}-worker" - retention_in_days = 14 - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-worker-logs" - }) -} - -locals { - apollo_common_environment = [ - { - name = "PREFERRED_URL_SCHEME" - value = "https" - }, - { - name = "DATABASE_HOSTNAME" - value = aws_db_instance.apollo.address - }, - { - name = "DATABASE_NAME" - value = aws_db_instance.apollo.db_name - }, - { - name = "DATABASE_USERNAME" - value = var.db_username - }, - { - name = "REDIS_HOSTNAME" - value = aws_elasticache_replication_group.apollo.primary_endpoint_address - }, - { - name = "REDIS_DATABASE" - value = "0" - }, - { - name = "ATTACHMENTS_USE_S3" - value = "true" - }, - { - name = "AWS_DEFAULT_BUCKET" - value = aws_s3_bucket.apollo_attachments.bucket - }, - { - name = "AWS_DEFAULT_REGION" - value = var.aws_region - }, - { - name = "TIMEZONE" - value = var.timezone - }, - { - name = "DEFAULT_EMAIL_SENDER" - value = var.default_email_sender - }, - { - name = "FLASK_ENV" - value = "production" - }, - { - name = "FLASK_APP" - value = "apollo.runner" - } - ] -} - -resource "aws_ecs_task_definition" "apollo_migration" { - family = "${local.name_prefix}-migration" - requires_compatibilities = ["FARGATE"] - network_mode = "awsvpc" - cpu = tostring(var.ecs_task_cpu) - memory = tostring(var.ecs_task_memory) - execution_role_arn = aws_iam_role.ecs_task_execution.arn - task_role_arn = aws_iam_role.apollo_task.arn - - container_definitions = jsonencode([ - { - name = "apollo-migration" - image = var.apollo_image_uri - essential = true - command = ["flask", "db", "upgrade"] - - environment = local.apollo_common_environment - - secrets = [ - { - name = "SECRET_KEY" - valueFrom = aws_secretsmanager_secret_version.apollo_secret_key.arn - }, - { - name = "DATABASE_PASSWORD" - valueFrom = aws_secretsmanager_secret_version.apollo_db_password.arn - }, - { - name = "AWS_ACCESS_KEY_ID" - valueFrom = aws_secretsmanager_secret_version.apollo_aws_access_key_id.arn - }, - { - name = "AWS_SECRET_ACCESS_KEY" - valueFrom = aws_secretsmanager_secret_version.apollo_aws_secret_access_key.arn - } - ] - - logConfiguration = { - logDriver = "awslogs" - options = { - awslogs-group = aws_cloudwatch_log_group.apollo_migration.name - awslogs-region = var.aws_region - awslogs-stream-prefix = "ecs" - } - } - } - ]) - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-migration-taskdef" - }) -} - -resource "aws_ecs_task_definition" "apollo_web" { - family = "${local.name_prefix}-web" - requires_compatibilities = ["FARGATE"] - network_mode = "awsvpc" - cpu = tostring(var.ecs_task_cpu) - memory = tostring(var.ecs_task_memory) - execution_role_arn = aws_iam_role.ecs_task_execution.arn - task_role_arn = aws_iam_role.apollo_task.arn - - container_definitions = jsonencode([ - { - name = "apollo-web" - image = var.apollo_image_uri - essential = true - command = ["gunicorn", "-c", "gunicorn.py", "apollo.runner"] - - portMappings = [ - { - containerPort = var.app_port - hostPort = var.app_port - protocol = "tcp" - } - ] - - environment = local.apollo_common_environment - - secrets = [ - { - name = "SECRET_KEY" - valueFrom = aws_secretsmanager_secret_version.apollo_secret_key.arn - }, - { - name = "DATABASE_PASSWORD" - valueFrom = aws_secretsmanager_secret_version.apollo_db_password.arn - }, - { - name = "AWS_ACCESS_KEY_ID" - valueFrom = aws_secretsmanager_secret_version.apollo_aws_access_key_id.arn - }, - { - name = "AWS_SECRET_ACCESS_KEY" - valueFrom = aws_secretsmanager_secret_version.apollo_aws_secret_access_key.arn - } - ] - - logConfiguration = { - logDriver = "awslogs" - options = { - awslogs-group = aws_cloudwatch_log_group.apollo_web.name - awslogs-region = var.aws_region - awslogs-stream-prefix = "ecs" - } - } - } - ]) - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-web-taskdef" - }) -} - -resource "aws_ecs_task_definition" "apollo_worker" { - family = "${local.name_prefix}-worker" - requires_compatibilities = ["FARGATE"] - network_mode = "awsvpc" - cpu = tostring(var.ecs_task_cpu) - memory = tostring(var.ecs_task_memory) - execution_role_arn = aws_iam_role.ecs_task_execution.arn - task_role_arn = aws_iam_role.apollo_task.arn - - container_definitions = jsonencode([ - { - name = "apollo-worker" - image = var.apollo_image_uri - essential = true - command = ["celery", "--app=apollo.runner", "worker", "--beat", "--loglevel=WARNING", "--concurrency=2", "--without-gossip", "--without-mingle", "--optimization=fair"] - - environment = local.apollo_common_environment - - secrets = [ - { - name = "SECRET_KEY" - valueFrom = aws_secretsmanager_secret_version.apollo_secret_key.arn - }, - { - name = "DATABASE_PASSWORD" - valueFrom = aws_secretsmanager_secret_version.apollo_db_password.arn - }, - { - name = "AWS_ACCESS_KEY_ID" - valueFrom = aws_secretsmanager_secret_version.apollo_aws_access_key_id.arn - }, - { - name = "AWS_SECRET_ACCESS_KEY" - valueFrom = aws_secretsmanager_secret_version.apollo_aws_secret_access_key.arn - } - ] - - logConfiguration = { - logDriver = "awslogs" - options = { - awslogs-group = aws_cloudwatch_log_group.apollo_worker.name - awslogs-region = var.aws_region - awslogs-stream-prefix = "ecs" - } - } - } - ]) - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-worker-taskdef" - }) -} - -resource "aws_secretsmanager_secret" "apollo_secret_key" { - name = "${local.name_prefix}/apollo/secret-key" - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-apollo-secret-key" - }) -} - -resource "aws_secretsmanager_secret_version" "apollo_secret_key" { - secret_id = aws_secretsmanager_secret.apollo_secret_key.id - secret_string = var.secret_key -} - -resource "aws_secretsmanager_secret" "apollo_db_password" { - name = "${local.name_prefix}/apollo/db-password" - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-apollo-db-password" - }) -} - -resource "aws_secretsmanager_secret_version" "apollo_db_password" { - secret_id = aws_secretsmanager_secret.apollo_db_password.id - secret_string = var.db_password -} - -resource "aws_lb" "apollo" { - name = "${local.name_prefix}-alb" - internal = false - load_balancer_type = "application" - security_groups = [aws_security_group.alb.id] - subnets = aws_subnet.public[*].id - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-alb" - }) -} - -resource "aws_lb_target_group" "apollo_web" { - name = "${local.name_prefix}-web-tg" - port = var.app_port - protocol = "HTTP" - target_type = "ip" - vpc_id = aws_vpc.apollo.id - - health_check { - enabled = true - path = var.health_check_path - port = "traffic-port" - protocol = "HTTP" - matcher = "200-399" - healthy_threshold = 2 - unhealthy_threshold = 5 - timeout = 5 - interval = 30 - } - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-web-tg" - }) -} - -resource "aws_lb_listener" "apollo_https" { - load_balancer_arn = aws_lb.apollo.arn - port = 443 - protocol = "HTTPS" - ssl_policy = "ELBSecurityPolicy-2016-08" - certificate_arn = var.apollo_certificate_arn - - default_action { - type = "forward" - target_group_arn = aws_lb_target_group.apollo_web.arn - } -} - -resource "aws_lb_listener" "apollo_http_redirect" { - load_balancer_arn = aws_lb.apollo.arn - port = 80 - protocol = "HTTP" - - default_action { - type = "redirect" - - redirect { - port = "443" - protocol = "HTTPS" - status_code = "HTTP_301" - } - } -} - -resource "aws_ecs_service" "apollo_web" { - name = "${local.name_prefix}-web" - cluster = aws_ecs_cluster.apollo.id - task_definition = aws_ecs_task_definition.apollo_web.arn - desired_count = 1 - launch_type = "FARGATE" - - deployment_minimum_healthy_percent = 50 - deployment_maximum_percent = 200 - - network_configuration { - subnets = aws_subnet.public[*].id - security_groups = [aws_security_group.web.id] - assign_public_ip = true - } - - load_balancer { - target_group_arn = aws_lb_target_group.apollo_web.arn - container_name = "apollo-web" - container_port = var.app_port - } - - depends_on = [ - aws_lb_listener.apollo_https - ] - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-web-service" - }) -} - -resource "aws_ecs_service" "apollo_worker" { - name = "${local.name_prefix}-worker" - cluster = aws_ecs_cluster.apollo.id - task_definition = aws_ecs_task_definition.apollo_worker.arn - desired_count = 1 - launch_type = "FARGATE" - - deployment_minimum_healthy_percent = 0 - deployment_maximum_percent = 100 - - network_configuration { - subnets = aws_subnet.public[*].id - security_groups = [aws_security_group.worker.id] - assign_public_ip = true - } - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-worker-service" - }) -} - -data "aws_route53_zone" "cocitizen" { - name = "cocitizen.com" - private_zone = false -} - -resource "aws_route53_record" "apollo" { - zone_id = data.aws_route53_zone.cocitizen.zone_id - name = var.apollo_hostname - type = "A" - - alias { - name = aws_lb.apollo.dns_name - zone_id = aws_lb.apollo.zone_id - evaluate_target_health = true - } -} - -data "aws_iam_policy_document" "ecs_task_execution_secrets" { - statement { - effect = "Allow" - actions = [ - "secretsmanager:GetSecretValue" - ] - resources = [ - aws_secretsmanager_secret.apollo_secret_key.arn, - aws_secretsmanager_secret.apollo_db_password.arn, - aws_secretsmanager_secret.apollo_aws_access_key_id.arn, - aws_secretsmanager_secret.apollo_aws_secret_access_key.arn - ] - } -} - -resource "aws_iam_role_policy" "ecs_task_execution_secrets" { - name = "${local.name_prefix}-ecs-task-execution-secrets" - role = aws_iam_role.ecs_task_execution.id - policy = data.aws_iam_policy_document.ecs_task_execution_secrets.json -} - -resource "aws_secretsmanager_secret" "apollo_aws_access_key_id" { - name = "${local.name_prefix}/apollo/aws-access-key-id" - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-apollo-aws-access-key-id" - }) -} - -resource "aws_secretsmanager_secret_version" "apollo_aws_access_key_id" { - secret_id = aws_secretsmanager_secret.apollo_aws_access_key_id.id - secret_string = var.aws_access_key_id -} - -resource "aws_secretsmanager_secret" "apollo_aws_secret_access_key" { - name = "${local.name_prefix}/apollo/aws-secret-access-key" - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-apollo-aws-secret-access-key" - }) -} - -resource "aws_secretsmanager_secret_version" "apollo_aws_secret_access_key" { - secret_id = aws_secretsmanager_secret.apollo_aws_secret_access_key.id - secret_string = var.aws_secret_access_key -} - -resource "aws_iam_user" "apollo_s3" { - name = var.apollo_s3_iam_username - - tags = merge(local.common_tags, { - Name = "${local.name_prefix}-apollo-s3-user" - }) -} - -data "aws_iam_policy_document" "apollo_s3_user_policy" { - statement { - effect = "Allow" - actions = [ - "s3:ListAllMyBuckets" - ] - resources = ["*"] - } - - statement { - effect = "Allow" - actions = [ - "s3:ListBucket" - ] - resources = [ - aws_s3_bucket.apollo_attachments.arn - ] - } - - statement { - effect = "Allow" - actions = [ - "s3:GetObject", - "s3:PutObject", - "s3:DeleteObject" - ] - resources = [ - "${aws_s3_bucket.apollo_attachments.arn}/*" - ] + Owner = var.owner } } -resource "aws_iam_user_policy" "apollo_s3_user_policy" { - name = "${local.name_prefix}-apollo-s3-user-policy" - user = aws_iam_user.apollo_s3.name - policy = data.aws_iam_policy_document.apollo_s3_user_policy.json -} \ No newline at end of file diff --git a/infra/terraform/network.tf b/infra/terraform/network.tf new file mode 100644 index 000000000..c17fe9a30 --- /dev/null +++ b/infra/terraform/network.tf @@ -0,0 +1,83 @@ +######################################## +# Network +######################################## + +resource "aws_vpc" "apollo" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-vpc" + }) +} + +resource "aws_subnet" "public" { + count = length(var.public_subnet_cidrs) + + vpc_id = aws_vpc.apollo.id + cidr_block = var.public_subnet_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + map_public_ip_on_launch = true + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-public-${count.index + 1}" + Tier = "public" + }) +} + +resource "aws_subnet" "private_app" { + count = length(var.private_app_subnet_cidrs) + + vpc_id = aws_vpc.apollo.id + cidr_block = var.private_app_subnet_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-private-app-${count.index + 1}" + Tier = "private-app" + }) +} + +resource "aws_subnet" "private_data" { + count = length(var.private_data_subnet_cidrs) + + vpc_id = aws_vpc.apollo.id + cidr_block = var.private_data_subnet_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-private-data-${count.index + 1}" + Tier = "private-data" + }) +} + +resource "aws_internet_gateway" "apollo" { + vpc_id = aws_vpc.apollo.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-igw" + }) +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.apollo.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-public-rt" + Tier = "public" + }) +} + +resource "aws_route" "public_internet_access" { + route_table_id = aws_route_table.public.id + destination_cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.apollo.id +} + +resource "aws_route_table_association" "public" { + count = length(aws_subnet.public) + + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} \ No newline at end of file diff --git a/infra/terraform/outputs-deployment.tf b/infra/terraform/outputs-deployment.tf new file mode 100644 index 000000000..78eca38c9 --- /dev/null +++ b/infra/terraform/outputs-deployment.tf @@ -0,0 +1,53 @@ +# Outputs describing this specific Apollo deployment instance. +# These values expose app-facing/runtime artifacts such as task definitions, service names, +# public hostname, and secret references used by the running deployment. + +output "apollo_migration_task_definition_arn" { + description = "ARN of the Apollo migration task definition" + value = aws_ecs_task_definition.apollo_migration.arn +} + +output "apollo_web_task_definition_arn" { + description = "ARN of the Apollo web task definition" + value = aws_ecs_task_definition.apollo_web.arn +} + +output "apollo_worker_task_definition_arn" { + description = "ARN of the Apollo worker task definition" + value = aws_ecs_task_definition.apollo_worker.arn +} + +output "apollo_secret_key_secret_arn" { + description = "ARN of the Apollo SECRET_KEY secret" + value = aws_secretsmanager_secret.apollo_secret_key.arn +} + +output "apollo_db_password_secret_arn" { + description = "ARN of the Apollo database password secret" + value = aws_secretsmanager_secret.apollo_db_password.arn +} + +output "apollo_aws_access_key_id_secret_arn" { + description = "ARN of the Apollo AWS access key ID secret" + value = aws_secretsmanager_secret.apollo_aws_access_key_id.arn +} + +output "apollo_aws_secret_access_key_secret_arn" { + description = "ARN of the Apollo AWS secret access key secret" + value = aws_secretsmanager_secret.apollo_aws_secret_access_key.arn +} + +output "apollo_web_service_name" { + description = "Name of the Apollo web ECS service" + value = aws_ecs_service.apollo_web.name +} + +output "apollo_worker_service_name" { + description = "Name of the Apollo worker ECS service" + value = aws_ecs_service.apollo_worker.name +} + +output "apollo_public_hostname" { + description = "Public hostname for Apollo" + value = aws_route53_record.apollo.fqdn +} \ No newline at end of file diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs-foundation.tf similarity index 69% rename from infra/terraform/outputs.tf rename to infra/terraform/outputs-foundation.tf index db1d96d3f..c0504565f 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs-foundation.tf @@ -1,3 +1,8 @@ +# Outputs describing the underlying AWS/Apollo foundation created by this Terraform root. +# These are designed to be general, not specific to any certain implementation. +# These values expose the reusable environment building blocks such as network, storage, +# database, Redis, load balancer, cluster, and IAM identities. + output "name_prefix" { description = "Common prefix for resource names" value = local.name_prefix @@ -118,31 +123,6 @@ output "apollo_task_role_arn" { value = aws_iam_role.apollo_task.arn } -output "apollo_migration_task_definition_arn" { - description = "ARN of the Apollo migration task definition" - value = aws_ecs_task_definition.apollo_migration.arn -} - -output "apollo_web_task_definition_arn" { - description = "ARN of the Apollo web task definition" - value = aws_ecs_task_definition.apollo_web.arn -} - -output "apollo_worker_task_definition_arn" { - description = "ARN of the Apollo worker task definition" - value = aws_ecs_task_definition.apollo_worker.arn -} - -output "apollo_secret_key_secret_arn" { - description = "ARN of the Apollo SECRET_KEY secret" - value = aws_secretsmanager_secret.apollo_secret_key.arn -} - -output "apollo_db_password_secret_arn" { - description = "ARN of the Apollo database password secret" - value = aws_secretsmanager_secret.apollo_db_password.arn -} - output "apollo_alb_dns_name" { description = "DNS name of the Apollo load balancer" value = aws_lb.apollo.dns_name @@ -153,31 +133,6 @@ output "apollo_alb_zone_id" { value = aws_lb.apollo.zone_id } -output "apollo_web_service_name" { - description = "Name of the Apollo web ECS service" - value = aws_ecs_service.apollo_web.name -} - -output "apollo_worker_service_name" { - description = "Name of the Apollo worker ECS service" - value = aws_ecs_service.apollo_worker.name -} - -output "apollo_public_hostname" { - description = "Public hostname for Apollo" - value = aws_route53_record.apollo.fqdn -} - -output "apollo_aws_access_key_id_secret_arn" { - description = "ARN of the Apollo AWS access key ID secret" - value = aws_secretsmanager_secret.apollo_aws_access_key_id.arn -} - -output "apollo_aws_secret_access_key_secret_arn" { - description = "ARN of the Apollo AWS secret access key secret" - value = aws_secretsmanager_secret.apollo_aws_secret_access_key.arn -} - output "apollo_s3_iam_username" { description = "IAM username for Apollo S3 attachment access" value = aws_iam_user.apollo_s3.name diff --git a/infra/terraform/redis.tf b/infra/terraform/redis.tf new file mode 100644 index 000000000..aa1f3f1f3 --- /dev/null +++ b/infra/terraform/redis.tf @@ -0,0 +1,35 @@ +######################################## +# Redis +######################################## + +resource "aws_elasticache_subnet_group" "apollo" { + name = "${local.name_prefix}-redis-subnet-group" + subnet_ids = aws_subnet.private_data[*].id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-redis-subnet-group" + }) +} + +resource "aws_elasticache_replication_group" "apollo" { + replication_group_id = "${local.name_prefix}-redis" + description = "Apollo Redis replication group" + engine = "redis" + engine_version = var.redis_engine_version + node_type = var.redis_node_type + port = var.redis_port + + num_cache_clusters = 1 + automatic_failover_enabled = false + multi_az_enabled = false + + subnet_group_name = aws_elasticache_subnet_group.apollo.name + security_group_ids = [aws_security_group.redis.id] + + at_rest_encryption_enabled = true + transit_encryption_enabled = false + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-redis" + }) +} diff --git a/infra/terraform/secrets.tf b/infra/terraform/secrets.tf new file mode 100644 index 000000000..cf328f88f --- /dev/null +++ b/infra/terraform/secrets.tf @@ -0,0 +1,55 @@ +######################################## +# Secrets +######################################## + +resource "aws_secretsmanager_secret" "apollo_secret_key" { + name = "${local.name_prefix}/apollo/secret-key" + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-apollo-secret-key" + }) +} + +resource "aws_secretsmanager_secret_version" "apollo_secret_key" { + secret_id = aws_secretsmanager_secret.apollo_secret_key.id + secret_string = var.secret_key +} + +resource "aws_secretsmanager_secret" "apollo_db_password" { + name = "${local.name_prefix}/apollo/db-password" + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-apollo-db-password" + }) +} + +resource "aws_secretsmanager_secret_version" "apollo_db_password" { + secret_id = aws_secretsmanager_secret.apollo_db_password.id + secret_string = var.db_password +} + +resource "aws_secretsmanager_secret" "apollo_aws_access_key_id" { + name = "${local.name_prefix}/apollo/aws-access-key-id" + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-apollo-aws-access-key-id" + }) +} + +resource "aws_secretsmanager_secret_version" "apollo_aws_access_key_id" { + secret_id = aws_secretsmanager_secret.apollo_aws_access_key_id.id + secret_string = var.aws_access_key_id +} + +resource "aws_secretsmanager_secret" "apollo_aws_secret_access_key" { + name = "${local.name_prefix}/apollo/aws-secret-access-key" + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-apollo-aws-secret-access-key" + }) +} + +resource "aws_secretsmanager_secret_version" "apollo_aws_secret_access_key" { + secret_id = aws_secretsmanager_secret.apollo_aws_secret_access_key.id + secret_string = var.aws_secret_access_key +} diff --git a/infra/terraform/security.tf b/infra/terraform/security.tf new file mode 100644 index 000000000..d50cdff5c --- /dev/null +++ b/infra/terraform/security.tf @@ -0,0 +1,142 @@ +######################################## +# Security groups +######################################## + +resource "aws_security_group" "alb" { + name = "${local.name_prefix}-alb-sg" + description = "Security group for the Apollo load balancer" + vpc_id = aws_vpc.apollo.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-alb-sg" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "alb_https_in" { + security_group_id = aws_security_group.alb.id + cidr_ipv4 = "0.0.0.0/0" + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + description = "Allow HTTPS from the internet" +} + +resource "aws_vpc_security_group_egress_rule" "alb_all_out" { + security_group_id = aws_security_group.alb.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" + description = "Allow all outbound traffic" +} + +resource "aws_security_group" "web" { + name = "${local.name_prefix}-web-sg" + description = "Security group for Apollo web tasks" + vpc_id = aws_vpc.apollo.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-web-sg" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "web_from_alb" { + security_group_id = aws_security_group.web.id + referenced_security_group_id = aws_security_group.alb.id + from_port = var.app_port + to_port = var.app_port + ip_protocol = "tcp" + description = "Allow app traffic from the ALB" +} + +resource "aws_vpc_security_group_egress_rule" "web_all_out" { + security_group_id = aws_security_group.web.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" + description = "Allow all outbound traffic" +} + +resource "aws_security_group" "worker" { + name = "${local.name_prefix}-worker-sg" + description = "Security group for Apollo worker tasks" + vpc_id = aws_vpc.apollo.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-worker-sg" + }) +} + +resource "aws_vpc_security_group_egress_rule" "worker_all_out" { + security_group_id = aws_security_group.worker.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" + description = "Allow all outbound traffic" +} + +resource "aws_security_group" "rds" { + name = "${local.name_prefix}-rds-sg" + description = "Security group for Apollo PostgreSQL" + vpc_id = aws_vpc.apollo.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-rds-sg" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "rds_from_web" { + security_group_id = aws_security_group.rds.id + referenced_security_group_id = aws_security_group.web.id + from_port = 5432 + to_port = 5432 + ip_protocol = "tcp" + description = "Allow PostgreSQL from web tasks" +} + +resource "aws_vpc_security_group_ingress_rule" "rds_from_worker" { + security_group_id = aws_security_group.rds.id + referenced_security_group_id = aws_security_group.worker.id + from_port = 5432 + to_port = 5432 + ip_protocol = "tcp" + description = "Allow PostgreSQL from worker tasks" +} + +resource "aws_vpc_security_group_egress_rule" "rds_all_out" { + security_group_id = aws_security_group.rds.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" + description = "Allow all outbound traffic" +} + +resource "aws_security_group" "redis" { + name = "${local.name_prefix}-redis-sg" + description = "Security group for Apollo Redis" + vpc_id = aws_vpc.apollo.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-redis-sg" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "redis_from_web" { + security_group_id = aws_security_group.redis.id + referenced_security_group_id = aws_security_group.web.id + from_port = 6379 + to_port = 6379 + ip_protocol = "tcp" + description = "Allow Redis from web tasks" +} + +resource "aws_vpc_security_group_ingress_rule" "redis_from_worker" { + security_group_id = aws_security_group.redis.id + referenced_security_group_id = aws_security_group.worker.id + from_port = 6379 + to_port = 6379 + ip_protocol = "tcp" + description = "Allow Redis from worker tasks" +} + +resource "aws_vpc_security_group_egress_rule" "redis_all_out" { + security_group_id = aws_security_group.redis.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" + description = "Allow all outbound traffic" +} \ No newline at end of file diff --git a/infra/terraform/storage.tf b/infra/terraform/storage.tf new file mode 100644 index 000000000..0fa11586c --- /dev/null +++ b/infra/terraform/storage.tf @@ -0,0 +1,18 @@ +######################################## +# Storage +######################################## + +resource "aws_s3_bucket" "apollo_attachments" { + bucket = var.attachments_bucket_name + + tags = local.common_tags +} + +resource "aws_s3_bucket_public_access_block" "apollo_attachments" { + bucket = aws_s3_bucket.apollo_attachments.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} \ No newline at end of file diff --git a/infra/terraform/variables-deployment.tf b/infra/terraform/variables-deployment.tf new file mode 100644 index 000000000..c9f488cf7 --- /dev/null +++ b/infra/terraform/variables-deployment.tf @@ -0,0 +1,75 @@ +# Deployment-specific inputs for this particular Apollo instance. +# These variables capture app/runtime choices such as hostname, image, task sizing, +# secrets, email, timezone, and other settings that are expected to vary per deployment. + +variable "db_password" { + type = string + description = "Master password for the Apollo database" + sensitive = true +} + +variable "apollo_image_uri" { + type = string + description = "Apollo container image URI in ECR" + default = "592016371171.dkr.ecr.us-east-1.amazonaws.com/apollo:2026-03-31.2" +} + +variable "ecs_task_cpu" { + type = number + description = "CPU units for Apollo ECS tasks" + default = 512 +} + +variable "ecs_task_memory" { + type = number + description = "Memory (MiB) for Apollo ECS tasks" + default = 1024 +} + +variable "secret_key" { + type = string + description = "Flask secret key for Apollo" + sensitive = true +} + +variable "timezone" { + type = string + description = "Default timezone for Apollo" + default = "America/New_York" +} + +variable "default_email_sender" { + type = string + description = "Default email sender for Apollo" + default = "witness@cocitizen.com" +} + +variable "apollo_certificate_arn" { + type = string + description = "ACM certificate ARN for the Apollo public hostname" + default = "arn:aws:acm:us-east-1:592016371171:certificate/4e27f9a4-6087-4ac1-ab39-b76731d7a450" +} + +variable "apollo_hostname" { + type = string + description = "Public hostname for Apollo" + default = "witness.cocitizen.com" +} + +variable "health_check_path" { + type = string + description = "HTTP path used by the ALB target group health check" + default = "/" +} + +variable "aws_access_key_id" { + type = string + description = "AWS access key ID used by Apollo for S3 attachments" + sensitive = true +} + +variable "aws_secret_access_key" { + type = string + description = "AWS secret access key used by Apollo for S3 attachments" + sensitive = true +} \ No newline at end of file diff --git a/infra/terraform/variables.tf b/infra/terraform/variables-foundation.tf similarity index 59% rename from infra/terraform/variables.tf rename to infra/terraform/variables-foundation.tf index 58dfb64b5..d6a20e5c0 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables-foundation.tf @@ -1,3 +1,7 @@ +# Shared foundation inputs for the reusable AWS/Apollo infrastructure shape. +# These variables define the environment, naming, network layout, and backing services +# that would likely exist in most deployments, independent of a particular instance. + variable "aws_region" { type = string description = "AWS region for Apollo infrastructure" @@ -16,6 +20,18 @@ variable "environment" { default = "dev" } +variable "owner" { + type = string + description = "Owner tag value for this deployment" + default = "cdoten" +} + +variable "attachments_bucket_name" { + type = string + description = "Name of the S3 bucket used for Apollo attachments" + default = "cdoten-apollo-dev-attachments" +} + variable "vpc_cidr" { type = string description = "CIDR block for the Apollo VPC" @@ -64,12 +80,6 @@ variable "db_username" { default = "apollo_admin" } -variable "db_password" { - type = string - description = "Master password for the Apollo database" - sensitive = true -} - variable "db_instance_class" { type = string description = "RDS instance class for Apollo PostgreSQL" @@ -106,72 +116,6 @@ variable "redis_port" { default = 6379 } -variable "apollo_image_uri" { - type = string - description = "Apollo container image URI in ECR" - default = "592016371171.dkr.ecr.us-east-1.amazonaws.com/apollo:2026-03-31.2" -} - -variable "ecs_task_cpu" { - type = number - description = "CPU units for Apollo ECS tasks" - default = 512 -} - -variable "ecs_task_memory" { - type = number - description = "Memory (MiB) for Apollo ECS tasks" - default = 1024 -} - -variable "secret_key" { - type = string - description = "Flask secret key for Apollo" - sensitive = true -} - -variable "timezone" { - type = string - description = "Default timezone for Apollo" - default = "America/New_York" -} - -variable "default_email_sender" { - type = string - description = "Default email sender for Apollo" - default = "witness@cocitizen.com" -} - -variable "apollo_certificate_arn" { - type = string - description = "ACM certificate ARN for the Apollo public hostname" - default = "arn:aws:acm:us-east-1:592016371171:certificate/4e27f9a4-6087-4ac1-ab39-b76731d7a450" -} - -variable "apollo_hostname" { - type = string - description = "Public hostname for Apollo" - default = "witness.cocitizen.com" -} - -variable "health_check_path" { - type = string - description = "HTTP path used by the ALB target group health check" - default = "/" -} - -variable "aws_access_key_id" { - type = string - description = "AWS access key ID used by Apollo for S3 attachments" - sensitive = true -} - -variable "aws_secret_access_key" { - type = string - description = "AWS secret access key used by Apollo for S3 attachments" - sensitive = true -} - variable "apollo_s3_iam_username" { type = string description = "IAM username for Apollo's S3 attachment access" From 768ff66e555d48e99116c995e728ea0424228a20 Mon Sep 17 00:00:00 2001 From: Chris Doten Date: Thu, 2 Apr 2026 12:18:28 -0400 Subject: [PATCH 09/16] Updated readme with current status --- infra/README.md | 99 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 82 insertions(+), 17 deletions(-) diff --git a/infra/README.md b/infra/README.md index 9be98c2e4..ca6fa00a3 100644 --- a/infra/README.md +++ b/infra/README.md @@ -4,13 +4,31 @@ This directory contains the Terraform configuration for Apollo's AWS deployment. Apollo is expected to run as a Flask/Gunicorn web service plus a separate Celery worker, backed by PostgreSQL/PostGIS, Redis, and S3 attachments. -The infrastructure now also includes the first ECS/ALB/Route 53 deployment path for bringing that runtime up in AWS. +The infrastructure now includes an end-to-end AWS deployment path for bringing that runtime up in ECS behind an ALB with Route 53 and ACM. ## Structure - `infra/bootstrap/` creates and manages the S3 bucket used for Terraform remote state. - `infra/terraform/` contains the main Terraform stack for Apollo infrastructure. -- `infra/scripts/` contains helper scripts for repeatable infrastructure/deployment tasks such as building and pushing ECS-compatible container images. +- `infra/scripts/` contains helper scripts for repeatable infrastructure and deployment tasks. + +Within `infra/terraform/`, Terraform files are split by concern: + +- network and routing +- security groups +- storage +- database +- redis +- ecr +- iam +- secrets +- ecs +- dns / load balancing + +Variables and outputs are split into two categories: + +- **foundation**: reusable infrastructure-shape inputs and outputs that would likely exist in most Apollo deployments +- **deployment**: inputs and outputs specific to this particular deployment instance ## Current architecture @@ -34,15 +52,17 @@ The main Terraform stack currently creates: - one internet gateway - one public route table associated to the public subnets -The intended network tiering is: +The intended long-term network tiering is: - **public subnets** for the load balancer - **private app subnets** for ECS tasks - **private data subnets** for RDS and Redis +For bring-up, ECS tasks are currently running in the public subnets with public IPs enabled. This is a pragmatic temporary choice so tasks can reach required AWS services without first adding NAT gateways or VPC endpoints. + ### Security model -Security groups are defined for the future runtime layout: +Security groups are defined for: - ALB - web tasks @@ -50,7 +70,7 @@ Security groups are defined for the future runtime layout: - RDS PostgreSQL - Redis -The intended traffic flow, once the runtime layer is in place, is: +The intended traffic flow is: - internet -> ALB on `443` - ALB -> web tasks on the application port @@ -59,13 +79,16 @@ The intended traffic flow, once the runtime layer is in place, is: The worker service is not intended to receive direct inbound traffic. +Even with ECS tasks currently placed in public subnets for bring-up, security groups still limit inbound access so the web service is reached through the ALB and the worker does not receive direct inbound traffic. + ### Database - PostgreSQL runs on Amazon RDS. - The DB instance is in the private data subnets. - The DB is not publicly accessible. - The current configuration is tuned for development / early infrastructure bring-up rather than hardened production. -- Migrations should ensure the PostGIS extension is enabled as Apollo requires. +- Apollo requires PostGIS support. +- The one-off ECS migration task has successfully run against the AWS database. ### Redis @@ -75,7 +98,7 @@ The worker service is not intended to receive direct inbound traffic. ### Application runtime -The current Terraform stack now includes the first ECS runtime layer for Apollo: +The current Terraform stack includes the first full ECS runtime layer for Apollo: - ECS cluster - task execution role and task role @@ -92,6 +115,19 @@ Apollo currently uses one Docker image with different commands for three roles: - **web**: `gunicorn -c gunicorn.py apollo.runner` - **worker**: `celery --app=apollo.runner worker --beat --loglevel=WARNING --concurrency=2 --without-gossip --without-mingle --optimization=fair` +The migration task has successfully completed in ECS, and the web application is reachable at the public hostname. + +### S3 attachment authentication + +Apollo currently expects explicit AWS credential environment variables during S3 attachment initialization. In this deployment, that means: + +- `AWS_ACCESS_KEY_ID` +- `AWS_SECRET_ACCESS_KEY` + +These are stored in Secrets Manager and injected into the ECS task definitions. + +This is a bring-up-era compatibility choice driven by the current Apollo codebase. Longer term, it would be preferable to remove this requirement and rely on ECS task-role credentials instead. + ## Design priorities This infrastructure is being built with the following priority order: @@ -118,6 +154,8 @@ Examples include: - RDS `skip_final_snapshot = true` - RDS `deletion_protection = false` - single-AZ database deployment +- ECS tasks currently running in public subnets with public IPs for bring-up +- Apollo currently requiring explicit AWS access keys for S3 attachment initialization - secrets currently simple enough for bootstrapping rather than a final production secret-management pattern ## Deployment-specific configuration choices @@ -133,10 +171,11 @@ Examples of deployment-specific choices currently include: - Docker image tag/version used for ECS task definitions - health check path used by the ALB - runtime secrets such as the Flask `SECRET_KEY` and database password +- attachments bucket name +- owner tag value If this stack is reused for another Apollo deployment, these values are among the first things that should be reviewed and changed. - ## Working with Terraform ### Bootstrap stack @@ -145,7 +184,7 @@ Use `infra/bootstrap/` only for infrastructure that supports Terraform itself, p Typical workflow: -```bash +``` cd infra/bootstrap terraform init terraform plan @@ -158,18 +197,30 @@ Use `infra/terraform/` for the actual Apollo infrastructure. Typical workflow: -```bash +``` cd infra/terraform terraform init terraform plan terraform apply ``` +### Formatting and validation + +A useful basic check after reorganizing Terraform files is: + +``` +terraform fmt +terraform validate +terraform plan +``` + +`terraform fmt` only reformats files to Terraform's standard style. It does not change infrastructure. + ### Build and push helper -A helper script for building and pushing ECS-compatible container images lives under `infra/scripts/`. +Helper scripts for repetitive deployment tasks live under `infra/scripts/`. -Because local development may happen on Apple Silicon hardware while ECS is running x86_64 workloads, images intended for ECS should be built for `linux/amd64`. +Because local development may happen on Apple Silicon hardware while ECS is running `x86_64` workloads, images intended for ECS should be built for `linux/amd64`. ## Notes on state @@ -177,18 +228,32 @@ Because local development may happen on Apple Silicon hardware while ECS is runn - The local machine is no longer the source of truth for Terraform state. - `.terraform.lock.hcl` should be committed. - local `*.tfstate` files should not be committed. +- `terraform.tfvars` may contain sensitive deployment values and should not be committed. + +## Current validation status + +The following milestones have already been validated in AWS: + +- Terraform refactoring and file splitting completed with no infrastructure changes in `terraform plan` +- ECS migration task completed successfully +- web application loads at the public hostname +- login works +- default admin password was changed +- a new admin user was created and successfully used to log in + +This does not yet mean every application path has been fully validated, but it does confirm that the deployment is beyond initial infrastructure bring-up and into application-level stabilization. ## Near-term expected work -The current stack now includes the first ECS runtime layer, but Apollo is not yet fully proven in this environment. +The current stack now includes a working ECS runtime path, but Apollo is not yet fully proven in this environment. Likely next work includes: -- running the one-off migration task successfully in ECS -- confirming that Apollo migrations enable PostGIS cleanly in AWS -- verifying that the web service comes healthy behind the ALB -- verifying that the worker service starts and remains healthy +- verifying that the worker service starts cleanly and remains healthy +- testing uploads / attachments end to end against S3 +- testing additional core Apollo workflows beyond login and basic admin operations - deciding whether ECS tasks should remain in public subnets for bring-up or move back to private app subnets with NAT or VPC endpoints +- reducing reliance on explicit AWS access keys for S3 startup if the codebase can be adjusted - tightening secret handling and other dev-stage compromises before treating the deployment as production-ready ## Intent of the split between `bootstrap` and `terraform` From 80e5ab0be2c22d54489c8d93adfd3f380c053b21 Mon Sep 17 00:00:00 2001 From: Chris Doten Date: Mon, 6 Apr 2026 23:58:36 -0400 Subject: [PATCH 10/16] Creating test setup fixtures to get the system running. --- doc/setup/apollo-rohan-sample-locations.csv | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 doc/setup/apollo-rohan-sample-locations.csv diff --git a/doc/setup/apollo-rohan-sample-locations.csv b/doc/setup/apollo-rohan-sample-locations.csv new file mode 100644 index 000000000..647c786d0 --- /dev/null +++ b/doc/setup/apollo-rohan-sample-locations.csv @@ -0,0 +1,5 @@ +Country Name,Country ID,Province Name,Province ID,Town Name,Town ID,Voting Location Name,Voting Location ID,Registered Voters +Rohan,1,Westfold,11,Helm's Deep,111,Glittering Caves,1111,34 +Rohan,1,Westfold,11,Helm's Deep,111,Hornburg,1112,25 +Rohan,1,Kingsfolde,12,Edoras,121,Methuseld,1211,101 +Rohan,1,Kingsfolde,12,Edoras,121,Barrowfield,1212,12 \ No newline at end of file From 4b63272297ee57d97e90fc2493af7d37333b821a Mon Sep 17 00:00:00 2001 From: Chris Doten Date: Tue, 7 Apr 2026 00:07:14 -0400 Subject: [PATCH 11/16] Adding local config file examples --- .env.example | 12 ++++++++++++ .gitignore | 4 ++++ settings.example.ini | 8 ++++++++ 3 files changed, 24 insertions(+) create mode 100644 .env.example create mode 100644 settings.example.ini diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..f28293b82 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Local environment variables for running Apollo via Docker Compose. +# Copy this file to .env and replace values as needed for your machine. +# Do not commit real secrets in .env. +DATABASE_HOSTNAME=postgres +DATABASE_USERNAME=postgres +DATABASE_PASSWORD= +DATABASE_NAME=apollo + +REDIS_HOSTNAME=redis +REDIS_DATABASE=0 + +FLASK_ENV=development diff --git a/.gitignore b/.gitignore index de3e881df..23f6dc5f3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ .devcontainer .DS_Store .env* +!.env.example +settings.ini +!settings.example.ini + .Python .python-version .webassets-cache diff --git a/settings.example.ini b/settings.example.ini new file mode 100644 index 000000000..9df01210c --- /dev/null +++ b/settings.example.ini @@ -0,0 +1,8 @@ +; Local Apollo application settings. +; Copy this file to settings.ini and replace placeholder values as needed. +; Do not commit real secrets in settings.ini. +; SECRET_KEY is required for Apollo to start correctly. +[settings] +SECRET_KEY=local-dev-password +PREFERRED_URL_SCHEME=http +TIMEZONE=America/New_York From b2b0c10faf9e7087a0834f1f3e0b6c2d5f2a6816 Mon Sep 17 00:00:00 2001 From: Chris Doten Date: Tue, 7 Apr 2026 00:09:27 -0400 Subject: [PATCH 12/16] Looks like modern apollo doesn't need settings.ini --- .gitignore | 2 -- settings.example.ini | 8 -------- 2 files changed, 10 deletions(-) delete mode 100644 settings.example.ini diff --git a/.gitignore b/.gitignore index 23f6dc5f3..31f677d70 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,6 @@ .DS_Store .env* !.env.example -settings.ini -!settings.example.ini .Python .python-version diff --git a/settings.example.ini b/settings.example.ini deleted file mode 100644 index 9df01210c..000000000 --- a/settings.example.ini +++ /dev/null @@ -1,8 +0,0 @@ -; Local Apollo application settings. -; Copy this file to settings.ini and replace placeholder values as needed. -; Do not commit real secrets in settings.ini. -; SECRET_KEY is required for Apollo to start correctly. -[settings] -SECRET_KEY=local-dev-password -PREFERRED_URL_SCHEME=http -TIMEZONE=America/New_York From 99177e5be966dd7e84518e0a1f4055bafacdbdae Mon Sep 17 00:00:00 2001 From: Chris Doten Date: Thu, 9 Apr 2026 00:05:09 -0400 Subject: [PATCH 13/16] Updating terraform.tfvars example with current requirements --- infra/terraform/terraform.tfvars.example | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example index f687dd244..4aac7b30e 100644 --- a/infra/terraform/terraform.tfvars.example +++ b/infra/terraform/terraform.tfvars.example @@ -1,5 +1,7 @@ -aws_region = "us-east-1" -project_name = "apollo" -environment = "dev" -db_password = "agooddbpassword" -secret_key = "agoodsecretkeyforflask" +aws_region = "us-east-1" +project_name = "apollo" +environment = "dev" +db_password = "pickadbpassword" +secret_key = "thisisyoursecretkey" +aws_access_key_id = "LONGSTRINGOFUCGIBBERISH" +aws_secret_access_key = "muchlongerstringofmixedcaseandnumericgibberish" From 8acfdcec4a636d61660676e144f015475c71524a Mon Sep 17 00:00:00 2001 From: Chris Doten Date: Thu, 30 Apr 2026 00:27:14 -0400 Subject: [PATCH 14/16] Add Apollo demo fixtures and Docker build ignores --- .dockerignore | 12 ++++++++++++ dev/fixtures/README.md | 7 +++++++ dev/fixtures/forms/rohan-forms-small-valid.xls | Bin 0 -> 9728 bytes .../locations/rohan-locations-small-valid.csv | 5 +++++ .../rohan-participants-small-valid.csv | 5 +++++ 5 files changed, 29 insertions(+) create mode 100644 dev/fixtures/README.md create mode 100644 dev/fixtures/forms/rohan-forms-small-valid.xls create mode 100644 dev/fixtures/locations/rohan-locations-small-valid.csv create mode 100644 dev/fixtures/participants/rohan-participants-small-valid.csv diff --git a/.dockerignore b/.dockerignore index 0a58a515b..44a591986 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,7 +12,14 @@ settings.ini **/.env* **/.webassets-cache **/*.pyc +**/__pycache__/ +**/.pytest_cache/ +**/.ruff_cache/ +**/.mypy_cache/ **/node_modules/ +**/.terraform/ +**/*.tfstate +**/*.tfstate.* apollo-*.tar.gz bin/ circle.yml @@ -26,3 +33,8 @@ messages.mo share/ src/ uploads/ +htmlcov/ +.coverage +coverage.xml +dist/ +build/ diff --git a/dev/fixtures/README.md b/dev/fixtures/README.md new file mode 100644 index 000000000..afae68484 --- /dev/null +++ b/dev/fixtures/README.md @@ -0,0 +1,7 @@ +These files are local/dev import fixtures for manually testing Apollo imports: locations, participants, and forms + +Suggested conventions: +- `*-valid.*` = known-good import files +- `*-invalid.*` = intentionally broken files for validation/error-path testing +- keep files small and easy to inspect by eye +- avoid real or sensitive data \ No newline at end of file diff --git a/dev/fixtures/forms/rohan-forms-small-valid.xls b/dev/fixtures/forms/rohan-forms-small-valid.xls new file mode 100644 index 0000000000000000000000000000000000000000..0a9779f722870722952a6f32713e7ffaa0aa864e GIT binary patch literal 9728 zcmeHNU2GLa6h8N-e^5$qDJZD87Ae2-BWg6UT#)b=rIeqD8k6hx-f6pb?{3-M3)BZi zv5zDsK52X*VvH{+FaAU&`sRZPMjuQ}Oh6xfGSS2buzugn?oGR~P-0SSa_44e_nbLr z=FB0`1XYgD|=uScV?lJUMOS@|gcHENMxa6K<$((il9n#1yhw!zNOhQ7=K$$-vDUYg_`Jax!& zZC#hH%er2o>kqZY7rNe|IcIg@42e>SYlx664!{PrqO1-5%dx)I3p^5>)_@&_lt@|D%#&#fOa zVu}34Fei_4Ba6*kw$YOba0sCl?@`&%0RX8D? za&{3}tzvt&J03ckcX6@dRaInH?8u&wWwNS^ybd{Dvt1_|FO^4Bc{IEx@8))c{}3(= zAB~SG+&sIgnnYZCP`L|5%Dxvyf$exv2(CJX=QgxDVPsLVRqBHyaf}6O$T@}fAwQ^= z*x?e9Kpn3+fhws}W!J9Sk>h(XyKKAVnrla@RQ1(T^inPds^UbdQaa&BDhNxFJ&d)+ z@lL>I!_+*;25K1NE67ig#k2=q+Z)v^ExAlm6D}#w9&}X&)8ZJVaKsK&*nz3n0?)5S zr6EVTl@QGp6_x{MjJCoht{RFk(LljoaNvi)86Js@cV4V{WpZKbVSmK-tRr@H%vIJN zxB$89@Ikm^*S_7mo_uQmk)u!s$)=9Wj4+R5?SQYwfRTVhp`WU@5%8sl7JBY@u zox>2Du2k~1%ZL>13qWG8?^XN&4+s1J4{+oDP;6vAN%`n+eP)giWJ_uf18MJEtwui}a$^j=F;Ez`Rk=OIIfn!nF^6 z|Ng+>K(VAbr@>+2O17GrMkeJ|YCsw2M*(h75H?w_H8$w_HOk0c`gw(}yCDOw^bRbI zANHKlGOz2_UW~I%mmi92raZ63^Tcn5K3zHSYjGW(FYLYgQul=)i|3?A+F*S~|IWz9 z%*M>yZ?Swszxm3^O6=X#$PumOdkEUjXqw^a`R1;GfwQwpu86pwHdDT*yI%?(KaY&6sc`cj)h2T)8lDv zgX=`w;F>!aYUlJjvNqP98Oe#ZK{B%(wewngU98=NdwOj03i1GI&f6e4v7EWDAe&-s zdLylEkep~YPxD%{*4_*)jdI*&&QZ?U3GA4^D9@&H2j??2vnM!BaGjPn**nR&2Gw(vG0j&n30p5`m!nrDHyWCb&b!@)R40HcP}T1*jfIO4_>V~1nx z4Pt}FMrnAU;emz+8XjnPpy7dr2O1t|c%b2dh6iTn0Uo}1Ybg literal 0 HcmV?d00001 diff --git a/dev/fixtures/locations/rohan-locations-small-valid.csv b/dev/fixtures/locations/rohan-locations-small-valid.csv new file mode 100644 index 000000000..7743d198a --- /dev/null +++ b/dev/fixtures/locations/rohan-locations-small-valid.csv @@ -0,0 +1,5 @@ +Country Name,Country ID,Province Name,Province ID,Town Name,Town ID,Voting Location Name,Voting Location ID, +Rohan,1,Westfold,11,Helm's Deep,111,Glittering Caves,1111, +Rohan,1,Westfold,11,Helm's Deep,111,Hornburg,1112, +Rohan,1,Kingsfolde,12,Edoras,121,Methuseld,1211, +Rohan,1,Kingsfolde,12,Edoras,121,Barrowfield,1212, \ No newline at end of file diff --git a/dev/fixtures/participants/rohan-participants-small-valid.csv b/dev/fixtures/participants/rohan-participants-small-valid.csv new file mode 100644 index 000000000..b9da7bed0 --- /dev/null +++ b/dev/fixtures/participants/rohan-participants-small-valid.csv @@ -0,0 +1,5 @@ +Participant ID,Full Name,Location Code,Role,Gender,Organization,Phone 1 +1001,Eowyn Shieldarm,1111,OBS,F,The Eorlingas Association,+15555550101 +1002,Eomer Marshal,1112,OBS,M,The Eorlingas Association,+15555550102 +1003,Theoden King,1211,OBS,M,The Eorlingas Association,+15555550103 +1004,Grima Wormtongue,1212,OBS,M,"White Hand, Inc.",+15555550104 \ No newline at end of file From 3dedaf0da708672495e65dc7c52ebb45494b21aa Mon Sep 17 00:00:00 2001 From: Chris Doten Date: Thu, 30 Apr 2026 12:51:50 -0400 Subject: [PATCH 15/16] Creating new EFS terraform setup for a shared filesystem for managing upload/import documents; created new sample upload forms --- ...s => rohan-checklist-form-small-valid.xls} | Bin .../forms/rohan-incident-form-small-valid.xls | Bin 0 -> 5632 bytes .../rohan-participants-small-valid.csv | 10 ++-- doc/setup/apollo-rohan-sample-locations.csv | 5 -- infra/terraform/ecs.tf | 42 +++++++++++++++++ infra/terraform/efs.tf | 44 ++++++++++++++++++ infra/terraform/security.tf | 37 ++++++++++++++- infra/terraform/variables-deployment.tf | 2 +- 8 files changed, 128 insertions(+), 12 deletions(-) rename dev/fixtures/forms/{rohan-forms-small-valid.xls => rohan-checklist-form-small-valid.xls} (100%) create mode 100644 dev/fixtures/forms/rohan-incident-form-small-valid.xls delete mode 100644 doc/setup/apollo-rohan-sample-locations.csv create mode 100644 infra/terraform/efs.tf diff --git a/dev/fixtures/forms/rohan-forms-small-valid.xls b/dev/fixtures/forms/rohan-checklist-form-small-valid.xls similarity index 100% rename from dev/fixtures/forms/rohan-forms-small-valid.xls rename to dev/fixtures/forms/rohan-checklist-form-small-valid.xls diff --git a/dev/fixtures/forms/rohan-incident-form-small-valid.xls b/dev/fixtures/forms/rohan-incident-form-small-valid.xls new file mode 100644 index 0000000000000000000000000000000000000000..893b8eaeba729cc9abb415860fdd61e65ed1a540 GIT binary patch literal 5632 zcmeHLO>A355dNOyq)wVPahep`LU|NgpoBuj1te0JmQr%SY6DUWLNc4@P5hMS_tN*C z+lB*_mK>3gIDxoS>V->z3qJxXxN$&g1qUP~q!x~xkU-+lV!qjZjuJ}*Bczm|tmWOE z*_oZ$nc11OuUsx(J^#VhYiMix(1kymz35qlcR25D!Gqv>vngO}EO1WLT3baH=q=EZ zJ>B?p!`JJ?m*Veh$l?9`m+XNb`F#PiC`X}oSFjy0Iw(hU7;_462zMcmcQ|gu7Ngsc z={?hLGW~mo{xj3>H<;H=KZYS(b)LuNQhE0y+!5UGcy5PT+sNL*M~3HngV~D_d_|h( zD{n~1bF~|f$G!^wn+x0X{kQ;n@30Patb(f;rW-Hdx26mHDhH7Eg}a7dSvxpzf!{@@ zG;9BL^h-Pb&0HQhgXW72K?m~;@8c%x_8@7*C-vMs25?1kD}C(MBHz==JO**8Pf!J@ zPz7^|Z*XV(Ev~LpRaL3FjyN|;)J}dWB%Pbp970vs-9Vrwb+8`rqA*Eg<%elPtUk>- zvwYwusVkI}+!8^xW@A0&pXT~h6xS=V;U*x~CmMdNEBdq-sJcr1DBQq(Rp2!OmFh}8 z(j(l(^I~1~Q(di`j8YvZl~hd^0Y5yc0^cGHj9Vw-SN=# ztC}WY!kHN7na9fq$DcS-6_`tO}r``WTrFOC^zeO8;<);e^zL)&CIu&ewdWB`K}`)+2qM{ zhUPW1x{GoiHtpNeJ&We|az6j_(T}&!|5Un<^NWYCy)=CByV7~=;0vfq8S*)YQD@Y7 z`z>jolv<<1S|e5K2h-b1wYWwVax|mS-8`On$N}7dxy-bOGz*D!0%A>kIi+qnf-q)jco_1bL&#K-(_Uqxc~|Gjs-|gUV!+z@Y>6LaFB@B12rVC5Ep8iIQu~BEXCiYe zA>(b1H_O`+XsgxRxwbnbR$QD{#9w0ZmVevnZSH8@ck5pkp#_h&(~GT&Xx(t@(YuJd zc|J600`f3gtv>AUz=!hV&E8+hsZu>HBsrC}IfJB9v~H)=&?5M}$<}%ZiIpthIzoKI zWl({T)LpyQONj4`4C*5!70s@#CnQy@6;JKD+jttv=xsdl1!=f#zTnFQpReOKAAk hUXU+(ELmI*u#fQNC^}vH$MP4Szf&R0(ETg Date: Thu, 14 May 2026 09:08:34 -0400 Subject: [PATCH 16/16] Added variables for management of main scaling/sizing of EC2 and DB resources to make it easier to Incredible Hulk the Apollo system. --- infra/terraform/database.tf | 5 +++ infra/terraform/ecs.tf | 18 +++++----- infra/terraform/variables-deployment.tf | 44 ++++++++++++++++++++++--- infra/terraform/variables-foundation.tf | 4 +-- 4 files changed, 56 insertions(+), 15 deletions(-) diff --git a/infra/terraform/database.tf b/infra/terraform/database.tf index 37a16c54b..d96ecdfac 100644 --- a/infra/terraform/database.tf +++ b/infra/terraform/database.tf @@ -31,6 +31,11 @@ resource "aws_db_instance" "apollo" { publicly_accessible = false multi_az = false + # Set apply_immediately to make any changes upon "terraform apply" + # instead of waiting for the maintenance window. + # For routine production-like DB changes, leave this as false. + apply_immediately = false + skip_final_snapshot = true deletion_protection = false diff --git a/infra/terraform/ecs.tf b/infra/terraform/ecs.tf index d4286f762..8a9f7fa78 100644 --- a/infra/terraform/ecs.tf +++ b/infra/terraform/ecs.tf @@ -98,8 +98,8 @@ resource "aws_ecs_task_definition" "apollo_migration" { family = "${local.name_prefix}-migration" requires_compatibilities = ["FARGATE"] network_mode = "awsvpc" - cpu = tostring(var.ecs_task_cpu) - memory = tostring(var.ecs_task_memory) + cpu = tostring(var.migration_task_cpu) + memory = tostring(var.migration_task_memory) execution_role_arn = aws_iam_role.ecs_task_execution.arn task_role_arn = aws_iam_role.apollo_task.arn @@ -151,8 +151,8 @@ resource "aws_ecs_task_definition" "apollo_web" { family = "${local.name_prefix}-web" requires_compatibilities = ["FARGATE"] network_mode = "awsvpc" - cpu = tostring(var.ecs_task_cpu) - memory = tostring(var.ecs_task_memory) + cpu = tostring(var.web_task_cpu) + memory = tostring(var.web_task_memory) execution_role_arn = aws_iam_role.ecs_task_execution.arn task_role_arn = aws_iam_role.apollo_task.arn @@ -233,8 +233,8 @@ resource "aws_ecs_task_definition" "apollo_worker" { family = "${local.name_prefix}-worker" requires_compatibilities = ["FARGATE"] network_mode = "awsvpc" - cpu = tostring(var.ecs_task_cpu) - memory = tostring(var.ecs_task_memory) + cpu = tostring(var.worker_task_cpu) + memory = tostring(var.worker_task_memory) execution_role_arn = aws_iam_role.ecs_task_execution.arn task_role_arn = aws_iam_role.apollo_task.arn @@ -307,7 +307,7 @@ resource "aws_ecs_service" "apollo_web" { name = "${local.name_prefix}-web" cluster = aws_ecs_cluster.apollo.id task_definition = aws_ecs_task_definition.apollo_web.arn - desired_count = 1 + desired_count = var.web_desired_count launch_type = "FARGATE" deployment_minimum_healthy_percent = 50 @@ -338,7 +338,7 @@ resource "aws_ecs_service" "apollo_worker" { name = "${local.name_prefix}-worker" cluster = aws_ecs_cluster.apollo.id task_definition = aws_ecs_task_definition.apollo_worker.arn - desired_count = 1 + desired_count = var.worker_desired_count launch_type = "FARGATE" deployment_minimum_healthy_percent = 0 @@ -353,4 +353,4 @@ resource "aws_ecs_service" "apollo_worker" { tags = merge(local.common_tags, { Name = "${local.name_prefix}-worker-service" }) -} +} \ No newline at end of file diff --git a/infra/terraform/variables-deployment.tf b/infra/terraform/variables-deployment.tf index ce41bc8db..fe1f62c21 100644 --- a/infra/terraform/variables-deployment.tf +++ b/infra/terraform/variables-deployment.tf @@ -14,15 +14,51 @@ variable "apollo_image_uri" { default = "592016371171.dkr.ecr.us-east-1.amazonaws.com/apollo:2026-04-30.1" } -variable "ecs_task_cpu" { +variable "migration_task_cpu" { type = number - description = "CPU units for Apollo ECS tasks" + description = "CPU units for the one-off Apollo migration ECS task. 1024 = 1 vCPU." default = 512 } -variable "ecs_task_memory" { +variable "migration_task_memory" { type = number - description = "Memory (MiB) for Apollo ECS tasks" + description = "Memory in MiB for the one-off Apollo migration ECS task." + default = 1024 +} + +variable "web_desired_count" { + type = number + description = "Number of Apollo web tasks to run. Use 2+ for pilot/live use so one unhealthy or restarting task does not take down the site." + default = 1 +} + +variable "web_task_cpu" { + type = number + description = "CPU units for each Apollo web ECS task. 1024 = 1 vCPU. Pilot recommendation: 1024." + default = 512 +} + +variable "web_task_memory" { + type = number + description = "Memory in MiB for each Apollo web ECS task. Pilot recommendation: 4096." + default = 1024 +} + +variable "worker_desired_count" { + type = number + description = "Number of Apollo worker tasks. Keep at 1 while the worker command also runs Celery beat, unless beat is split into its own service." + default = 1 +} + +variable "worker_task_cpu" { + type = number + description = "CPU units for the Apollo worker ECS task. 1024 = 1 vCPU. Pilot recommendation: 1024." + default = 512 +} + +variable "worker_task_memory" { + type = number + description = "Memory in MiB for the Apollo worker ECS task. Pilot recommendation: 4096 for imports, generation tasks, and other long-running jobs." default = 1024 } diff --git a/infra/terraform/variables-foundation.tf b/infra/terraform/variables-foundation.tf index d6a20e5c0..9139d510b 100644 --- a/infra/terraform/variables-foundation.tf +++ b/infra/terraform/variables-foundation.tf @@ -82,7 +82,7 @@ variable "db_username" { variable "db_instance_class" { type = string - description = "RDS instance class for Apollo PostgreSQL" + description = "RDS instance class for Apollo PostgreSQL/PostGIS. Dev default: db.t4g.micro. Pilot recommendation: db.t4g.medium. Larger live event candidate: db.m7g.large after load testing." default = "db.t4g.micro" } @@ -100,7 +100,7 @@ variable "db_engine_version" { variable "redis_node_type" { type = string - description = "ElastiCache node type for Apollo Redis" + description = "ElastiCache node type for Apollo Redis/Celery. Dev default: cache.t4g.micro. Pilot default can remain micro unless queueing, Redis CPU, memory, or evictions show pressure." default = "cache.t4g.micro" }