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/.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 803785bac..31f677d70 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,10 @@ .devcontainer .DS_Store .env* +!.env.example + .Python .python-version -.vscode/ .webassets-cache *.pyc *.sublime-project @@ -22,4 +23,14 @@ node_modules/ pip-selfcheck.json share/ uploads/ -version.ini \ No newline at end of file +version.ini + +# Development +.claude/ +.vscode/ + +# Terraform Setup +.terraform/ +infra/terraform/terraform.tfvars +*.tfstate +*.tfstate.* 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-checklist-form-small-valid.xls b/dev/fixtures/forms/rohan-checklist-form-small-valid.xls new file mode 100644 index 000000000..0a9779f72 Binary files /dev/null and b/dev/fixtures/forms/rohan-checklist-form-small-valid.xls differ 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 000000000..893b8eaeb Binary files /dev/null and b/dev/fixtures/forms/rohan-incident-form-small-valid.xls differ 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..b69467f25 --- /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,Password +1001,Éowyn Shieldarm,1111,OBS,F,The Eorlingas Association,+15555550101,1001 +1002,Éomer ap Éomund,1112,OBS,M,The Eorlingas Association,+15555550102,1002 +1003,Théoden Ednew,1211,OBS,M,The Eorlingas Association,+15555550103,1003 +1004,Grima Wormtongue,1212,OBS,M,"White Hand, Inc.",+15555550104,1004 \ No newline at end of file diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 000000000..ca6fa00a3 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,266 @@ +# Apollo Infrastructure + +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 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 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 + +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 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: + +- 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. + +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. +- Apollo requires PostGIS support. +- The one-off ECS migration task has successfully run against the AWS database. + +### Redis + +- Redis runs on Amazon ElastiCache. +- Redis is in the private data subnets. +- Redis is intended for Apollo's Celery/background-task queueing. + +### Application runtime + +The current Terraform stack includes the first full 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` + +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: + +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 +- 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 + +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 +- 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 + +Use `infra/bootstrap/` only for infrastructure that supports Terraform itself, primarily the remote state bucket. + +Typical workflow: + +``` +cd infra/bootstrap +terraform init +terraform plan +terraform apply +``` + +### Main Apollo stack + +Use `infra/terraform/` for the actual Apollo infrastructure. + +Typical workflow: + +``` +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 + +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`. + +## 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. +- `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 a working ECS runtime path, but Apollo is not yet fully proven in this environment. + +Likely next work includes: + +- 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` + +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/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/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/.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/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 + } +} diff --git a/infra/terraform/database.tf b/infra/terraform/database.tf new file mode 100644 index 000000000..d96ecdfac --- /dev/null +++ b/infra/terraform/database.tf @@ -0,0 +1,48 @@ +######################################## +# 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 + + # 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 + + 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..8a9f7fa78 --- /dev/null +++ b/infra/terraform/ecs.tf @@ -0,0 +1,356 @@ +######################################## +# 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.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 + + 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.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 + + 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" + } + ] + + mountPoints = [ + { + sourceVolume = "apollo-uploads" + containerPath = "/app/uploads" + readOnly = false + } + ] + + 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" + } + } + } + ]) + + volume { + name = "apollo-uploads" + + efs_volume_configuration { + file_system_id = aws_efs_file_system.apollo_uploads.id + transit_encryption = "ENABLED" + + authorization_config { + access_point_id = aws_efs_access_point.apollo_uploads.id + } + } + } + + 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.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 + + 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 + + mountPoints = [ + { + sourceVolume = "apollo-uploads" + containerPath = "/app/uploads" + readOnly = false + } + ] + + 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" + } + } + } + ]) + + volume { + name = "apollo-uploads" + + efs_volume_configuration { + file_system_id = aws_efs_file_system.apollo_uploads.id + transit_encryption = "ENABLED" + + authorization_config { + access_point_id = aws_efs_access_point.apollo_uploads.id + } + } + } + + 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 = var.web_desired_count + 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 = var.worker_desired_count + 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" + }) +} \ No newline at end of file diff --git a/infra/terraform/efs.tf b/infra/terraform/efs.tf new file mode 100644 index 000000000..a4d853111 --- /dev/null +++ b/infra/terraform/efs.tf @@ -0,0 +1,44 @@ +######################################## +# Shared uploads filesystem +######################################## + +resource "aws_efs_file_system" "apollo_uploads" { + creation_token = "${local.name_prefix}-uploads" + + encrypted = true + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-uploads-efs" + }) +} + +resource "aws_efs_access_point" "apollo_uploads" { + file_system_id = aws_efs_file_system.apollo_uploads.id + + posix_user { + uid = 1000 + gid = 1000 + } + + root_directory { + path = "/uploads" + + creation_info { + owner_uid = 1000 + owner_gid = 1000 + permissions = "0775" + } + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-uploads-access-point" + }) +} + +resource "aws_efs_mount_target" "apollo_uploads" { + count = length(aws_subnet.public) + + file_system_id = aws_efs_file_system.apollo_uploads.id + subnet_id = aws_subnet.public[count.index].id + security_groups = [aws_security_group.efs.id] +} 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 new file mode 100644 index 000000000..a4b8cee2a --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,16 @@ +######################################## +# Shared locals +# Naming and tagging tools used across this root +######################################## + +locals { + name_prefix = "${var.project_name}-${var.environment}" + + common_tags = { + Project = var.project_name + Environment = var.environment + ManagedBy = "Terraform" + Owner = var.owner + } +} + 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-foundation.tf b/infra/terraform/outputs-foundation.tf new file mode 100644 index 000000000..c0504565f --- /dev/null +++ b/infra/terraform/outputs-foundation.tf @@ -0,0 +1,144 @@ +# 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 +} + +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 +} + +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 +} + +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_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_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/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/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..9b7a6f395 --- /dev/null +++ b/infra/terraform/security.tf @@ -0,0 +1,177 @@ +######################################## +# 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" "efs" { + name = "${local.name_prefix}-efs-sg" + description = "Security group for Apollo shared uploads EFS" + vpc_id = aws_vpc.apollo.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-efs-sg" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "efs_from_web" { + security_group_id = aws_security_group.efs.id + referenced_security_group_id = aws_security_group.web.id + from_port = 2049 + to_port = 2049 + ip_protocol = "tcp" + description = "Allow NFS from web tasks" +} + +resource "aws_vpc_security_group_ingress_rule" "efs_from_worker" { + security_group_id = aws_security_group.efs.id + referenced_security_group_id = aws_security_group.worker.id + from_port = 2049 + to_port = 2049 + ip_protocol = "tcp" + description = "Allow NFS from worker tasks" +} + +resource "aws_vpc_security_group_egress_rule" "efs_all_out" { + security_group_id = aws_security_group.efs.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" +} 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/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example new file mode 100644 index 000000000..4aac7b30e --- /dev/null +++ b/infra/terraform/terraform.tfvars.example @@ -0,0 +1,7 @@ +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" diff --git a/infra/terraform/variables-deployment.tf b/infra/terraform/variables-deployment.tf new file mode 100644 index 000000000..fe1f62c21 --- /dev/null +++ b/infra/terraform/variables-deployment.tf @@ -0,0 +1,111 @@ +# 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-04-30.1" +} + +variable "migration_task_cpu" { + type = number + description = "CPU units for the one-off Apollo migration ECS task. 1024 = 1 vCPU." + default = 512 +} + +variable "migration_task_memory" { + type = number + 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 +} + +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-foundation.tf b/infra/terraform/variables-foundation.tf new file mode 100644 index 000000000..9139d510b --- /dev/null +++ b/infra/terraform/variables-foundation.tf @@ -0,0 +1,123 @@ +# 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" + 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" +} + +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" + 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_instance_class" { + type = string + 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" +} + +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" +} + +variable "redis_node_type" { + type = string + 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" +} + +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 +} + +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 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" + } + } +}