diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh index d0469bb..98cceeb 100755 --- a/.devcontainer/onCreate.sh +++ b/.devcontainer/onCreate.sh @@ -16,6 +16,6 @@ echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https:// sudo apt update sudo apt-get install -y terraform -# Install Azure CLI +# Install AWS CLI # TODO move this into base image -sudo apt-get install -y azure-cli +sudo apt-get install -y awscli diff --git a/.github/workflows/build_deploy_and_test.yml b/.github/workflows/build_deploy_and_test.yml index 1721bce..6c76378 100644 --- a/.github/workflows/build_deploy_and_test.yml +++ b/.github/workflows/build_deploy_and_test.yml @@ -69,76 +69,32 @@ jobs: build_for_aws: name: Build for AWS runs-on: ubuntu-latest - if: false - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Log in to GitHub container registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ github.token }} - - name: Lowercase the repo name and username - run: echo "REPO=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV} - - name: Build and push container image to registry - uses: docker/build-push-action@v6 - with: - push: true - tags: | - ghcr.io/${{ env.REPO }}-aws:${{ github.sha }} - ghcr.io/${{ env.REPO }}-aws:latest - file: ./Dockerfile - build-args: | - MYSQL_HOST=${{ vars.AWS_MYSQL_HOST }} - MYSQL_TCP_PORT=${{ vars.AWS_MYSQL_TCP_PORT }} - MYSQL_USER=${{ vars.AWS_MYSQL_USER }} - MYSQL_PASSWORD=${{ secrets.AWS_MYSQL_PASSWORD }} - MYSQL_DATABASE=${{ vars.AWS_MYSQL_DATABASE }} - REDIS_HOST=${{ vars.AWS_REDIS_HOST }} - REDIS_AUTH=${{ secrets.AWS_REDIS_AUTH }} - HASH_SALT=${{ secrets.AWS_HASH_SALT }} - - build_for_azure: - name: Build for Azure - runs-on: ubuntu-latest if: github.ref_name == 'main' permissions: contents: read - packages: write + id-token: write steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to GitHub container registry - uses: docker/login-action@v3 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ vars.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ vars.AWS_REGION }} + - name: Log in to Amazon ECR + uses: aws-actions/amazon-ecr-login@v2 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ github.token }} - - name: Lowercase the repo name and username - run: echo "REPO=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV} - - name: Build and push container image to registry + mask-password: 'true' + - name: Build and push container image to ECR uses: docker/build-push-action@v6 with: push: true tags: | - ghcr.io/${{ env.REPO }}-azure:${{ github.sha }} - ghcr.io/${{ env.REPO }}-azure:latest + ${{ vars.ECR_REPOSITORY_URL }}:${{ github.sha }} + ${{ vars.ECR_REPOSITORY_URL }}:latest file: ./Dockerfile - build-args: | - MYSQL_HOST=${{ vars.AZURE_MYSQL_HOST }} - MYSQL_TCP_PORT=${{ vars.AZURE_MYSQL_TCP_PORT }} - MYSQL_USER=${{ vars.AZURE_MYSQL_USER }} - MYSQL_PASSWORD=${{ secrets.AZURE_MYSQL_PASSWORD }} - MYSQL_DATABASE=${{ vars.AZURE_MYSQL_DATABASE }} - REDIS_HOST=${{ vars.AZURE_REDIS_HOST }} - REDIS_AUTH=${{ secrets.AZURE_REDIS_AUTH }} - HASH_SALT=${{ secrets.AZURE_HASH_SALT }} lint: name: Check lint @@ -312,36 +268,43 @@ jobs: if: github.ref_name == 'main' runs-on: ubuntu-latest needs: [build_for_aws] + permissions: + contents: read steps: - - name: Deploy to AWS - run: echo "Hello, world!" - - deploy_to_azure: - name: Deploy to Azure - if: github.ref_name == 'main' - runs-on: ubuntu-latest - needs: [build_for_azure] - environment: - name: 'Development' - url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} - steps: - - name: Lowercase the repo name and username - run: echo "REPO=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV} - - name: Deploy to Azure Web App - id: deploy-to-webapp - uses: azure/webapps-deploy@v2 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 with: - app-name: ${{ env.AZURE_WEBAPP_NAME }} - publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} - images: 'ghcr.io/${{ env.REPO }}-azure:${{ github.sha }}' - - name: Drush deploy + aws-access-key-id: ${{ vars.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ vars.AWS_REGION }} + - name: Update ECS service with new image run: | - az webapp create-remote-connection \ - --resource-group drupal-example-stirred-dove \ - --name drupal-example-precious-seasnail \ - --port 16385 & - sleep 30 - sshpass -pDocker\! ssh root@127.0.0.1 -m hmac-sha1 -p 16385 -o "StrictHostKeyChecking no" /var/www/vendor/bin/drush deploy + # Get the current task definition + TASK_DEF=$(aws ecs describe-task-definition \ + --task-definition ${{ vars.AWS_ECS_TASK_DEFINITION }} \ + --region ${{ vars.AWS_REGION }} \ + --query 'taskDefinition' --output json) + + # Update the image in the task definition + NEW_TASK_DEF=$(echo "$TASK_DEF" | jq \ + --arg IMAGE "${{ vars.ECR_REPOSITORY_URL }}:${{ github.sha }}" \ + '.containerDefinitions[0].image = $IMAGE | + del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)') + + # Register the new task definition + NEW_TASK_DEF_ARN=$(aws ecs register-task-definition \ + --region ${{ vars.AWS_REGION }} \ + --cli-input-json "$(echo "$NEW_TASK_DEF" | jq -c .)" \ + --query 'taskDefinition.taskDefinitionArn' \ + --output text) + + # Update the ECS service to use the new task definition + aws ecs update-service \ + --cluster ${{ vars.AWS_ECS_CLUSTER }} \ + --service ${{ vars.AWS_ECS_SERVICE }} \ + --task-definition "$NEW_TASK_DEF_ARN" \ + --region ${{ vars.AWS_REGION }} \ + --force-new-deployment e2e_test: name: Feature tests diff --git a/Dockerfile b/Dockerfile index ad38e62..5f0d546 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,3 @@ -# check=skip=SecretsUsedInArgOrEnv - FROM ghcr.io/uceap/devcontainer-drupal:v2.3.0 # Install SSH server @@ -12,15 +10,6 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ COPY docker-uceap-entrypoint /usr/local/bin/docker-uceap-entrypoint ENTRYPOINT ["docker-uceap-entrypoint"] -ARG MYSQL_HOST -ARG MYSQL_TCP_PORT -ARG MYSQL_USER -ARG MYSQL_PASSWORD -ARG MYSQL_DATABASE -ARG REDIS_HOST -ARG REDIS_AUTH -ARG HASH_SALT - COPY build /var/www/build COPY config /var/www/config COPY composer.json /var/www/ @@ -28,7 +17,6 @@ COPY web /var/www/web WORKDIR /var/www -RUN composer initialize-container && \ - composer install --no-dev --no-interaction --no-progress --optimize-autoloader && \ +RUN composer install --no-dev --no-interaction --no-progress --optimize-autoloader && \ sed -i 's-/var/www/html-/var/www/web-' /etc/apache2/sites-available/*.conf && \ sed -i 's/# Listen\s*80$/Listen 80/' /etc/apache2/ports.conf diff --git a/docker-uceap-entrypoint b/docker-uceap-entrypoint index 8c2e892..1b6c6c8 100755 --- a/docker-uceap-entrypoint +++ b/docker-uceap-entrypoint @@ -1,6 +1,10 @@ #!/bin/sh set -e +# Initialize Drupal container with runtime environment variables +# This runs at container startup with secrets from AWS Secrets Manager +cd /var/www && composer initialize-container + service ssh start exec apache2-foreground diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..6867021 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,68 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.17.0" + constraints = "~> 6.0" + hashes = [ + "h1:65zxvr7oxROr5hqTWQtoS5HsGOBwUko7douoc9Azptc=", + "zh:157063d66cd4b5fc650f20f56127e19c9da5d135f4231f9ca0c19a1c0bf6e29d", + "zh:2050dc03304b42204e6c58bbb1a2afd4feeac7db55d7c06be77c6b1e2ab46a0f", + "zh:2a7f7751eef636ca064700cc4574b9b54a2596d9e2e86b91c45127410d9724c6", + "zh:335fd7bb44bebfc4dd1db1c013947e1dde2518c6f2d846aac13b7314414ce461", + "zh:545c248d2eb601a7b45a34313096cae0a5201ccf31e7fd99428357ef800051e0", + "zh:57d19883a6367c245e885856a1c5395c4c743c20feff631ea4ec7b5e16826281", + "zh:66d4f080b8c268d65e8c4758ed57234e5a19deff6073ffc3753b9a4cc177b54e", + "zh:6ad50de35970f15e1ed41d39742290c1be80600b7df3a9fbb4c02f353b9586cf", + "zh:7af42fa531e4dcb3ddb09f71ca988e90626abbf56a45981c2a6c01d0b364a51b", + "zh:9a6a535a879314a9137ec9d3e858b7c490a962050845cf62620ba2bf4ae916a8", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:ca213e0262c8f686fcd40e3fc84d67b8eea1596de988c13d4a8ecd4522ede669", + "zh:cc4132f682e9bf17c0649928ad92af4da07ffe7bccfe615d955225cdcf9e7f09", + "zh:dfe6de43496d2e2b6dff131fef6ada1e15f1fbba3d47235c751564d22003d05e", + "zh:e37d035fa02693a3d47fe636076cce50b6579b6adc0a36a7cf0456a2331c99ec", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.7.2" + constraints = "~> 3.6" + hashes = [ + "h1:356j/3XnXEKr9nyicLUufzoF4Yr6hRy481KIxRVpK0c=", + "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f", + "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc", + "zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab", + "zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3", + "zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212", + "zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34", + "zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967", + "zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d", + "zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62", + "zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0", + ] +} + +provider "registry.terraform.io/integrations/github" { + version = "6.6.0" + constraints = "~> 6.0" + hashes = [ + "h1:Fp0RrNe+w167AQkVUWC1WRAsyjhhHN7aHWUky7VkKW8=", + "zh:0b1b5342db6a17de7c71386704e101be7d6761569e03fb3ff1f3d4c02c32d998", + "zh:2fb663467fff76852126b58315d9a1a457e3b04bec51f04bf1c0ddc9dfbb3517", + "zh:4183e557a1dfd413dae90ca4bac37dbbe499eae5e923567371f768053f977800", + "zh:48b2979f88fb55cdb14b7e4c37c44e0dfbc21b7a19686ce75e339efda773c5c2", + "zh:5d803fb06625e0bcf83abb590d4235c117fa7f4aa2168fa3d5f686c41bc529ec", + "zh:6f1dd094cbab36363583cda837d7ca470bef5f8abf9b19f23e9cd8b927153498", + "zh:772edb5890d72b32868f9fdc0a9a1d4f4701d8e7f8acb37a7ac530d053c776e3", + "zh:798f443dbba6610431dcef832047f6917fb5a4e184a3a776c44e6213fb429cc6", + "zh:cc08dfcc387e2603f6dbaff8c236c1254185450d6cadd6bad92879fe7e7dbce9", + "zh:d5e2c8d7f50f91d6847ddce27b10b721bdfce99c1bbab42a68fa271337d73d63", + "zh:e69a0045440c706f50f84a84ff8b1df520ec9bf757de4b8f9959f2ed20c3f440", + "zh:efc5358573a6403cbea3a08a2fcd2407258ac083d9134c641bdcb578966d8bdf", + "zh:f627a255e5809ec2375f79949c79417847fa56b9e9222ea7c45a463eb663f137", + "zh:f7c02f762e4cf1de7f58bde520798491ccdd54a5bd52278d579c146d1d07d4f0", + "zh:fbd1fee2c9df3aa19cf8851ce134dea6e45ea01cb85695c1726670c285797e25", + ] +} diff --git a/terraform/CIDR_ALLOCATION.md b/terraform/CIDR_ALLOCATION.md new file mode 100644 index 0000000..9db1f3d --- /dev/null +++ b/terraform/CIDR_ALLOCATION.md @@ -0,0 +1,86 @@ +# CIDR Allocation Registry + +## 10.0.0.0/8 - Internal Infrastructure Space + +This document tracks IP address allocation for all internal applications and infrastructure. + +## CIDR Block Allocation + +| CIDR Block | Purpose | Status | Notes | +|-----------------|----------------------------------|----------|-------| +| 10.0.0.0/16 | Shared Services (Reserved) | Reserved | VPN, monitoring, shared infrastructure | +| 10.1.0.0/16 - 10.100.0.0/16 | Additional shared infrastructure | Reserved | Future use | +| 10.101.0.0/16 | drupal-example (app_number=101) | Active | Drupal CMS application | +| 10.102.0.0/16 | (Next app, app_number=102) | Available | | +| 10.103.0.0/16 | (Next app, app_number=103) | Available | | +| ... | ... | Available | | +| 10.254.0.0/16 | (Last app, app_number=254) | Available | | + +## Application VPC Subnet Structure + +Each application VPC uses a consistent internal structure for predictable IP allocation. + +### Example: drupal-example (10.101.0.0/16) + +| CIDR Block | Tier | AZ | Use Case | Hosts | Gateway | +|-----------------|----------|--------|----------------|-------|---------| +| 10.101.0.0/24 | Public | us-west-2a | ALB | ~254 | IGW | +| 10.101.1.0/24 | Public | us-west-2b | ALB (HA) | ~254 | IGW | +| 10.101.10.0/24 | Private | us-west-2a | ECS Tasks | ~254 | NAT-GW | +| 10.101.11.0/24 | Private | us-west-2b | ECS Tasks (HA) | ~254 | NAT-GW | +| 10.101.20.0/24 | Database | us-west-2a | RDS/Redis | ~254 | None | +| 10.101.21.0/24 | Database | us-west-2b | RDS/Redis (HA) | ~254 | None | + +### Template for New Apps + +When deploying a new application, use this template with your assigned `app_number`: + +| CIDR Block | Tier | AZ | Use Case | +|---------------------|----------|--------|----------------| +| 10.{app_number}.0.0/24 | Public | AZ1 | Load Balancer | +| 10.{app_number}.1.0/24 | Public | AZ2 | Load Balancer | +| 10.{app_number}.10.0/24 | Private | AZ1 | Application | +| 10.{app_number}.11.0/24 | Private | AZ2 | Application | +| 10.{app_number}.20.0/24 | Database | AZ1 | Data Layer | +| 10.{app_number}.21.0/24 | Database | AZ2 | Data Layer | + +## Network Tiers + +### Public Tier (X.0.0/24, X.1.0/24) +- Internet-facing resources (ALB, NAT gateways) +- Ingress from internet allowed +- Routed through Internet Gateway + +### Private Tier (X.10.0/24, X.11.0/24) +- Application compute (ECS tasks) +- No direct internet access +- Egress through NAT Gateway for outbound internet access +- Ingress only from ALB + +### Database Tier (X.20.0/24, X.21.0/24) +- Data layer resources (RDS, ElastiCache) +- No internet access +- Ingress only from application tier +- No outbound internet access + +## Deployment Instructions + +To deploy a new application with this convention: + +1. **Assign an app_number**: Choose a number between 101-254 (update this registry first) +2. **Update this registry**: Add the app and its status +3. **Deploy with Terraform**: + ```bash + terraform apply -var="app_number=101" # For drupal-example + terraform apply -var="app_number=102" # For next app + ``` + +4. **Verify CIDR blocks** in AWS console match expected values + +## Benefits + +- **Predictable**: Each app's CIDR space is deterministic and easy to calculate +- **Readable**: Simple numeric offsets (0, 1, 10, 11, 20, 21) are easy to remember +- **Scalable**: Supports up to 154 applications +- **Reserved**: Lower CIDR space (10.0-10.100) reserved for shared infrastructure +- **Non-overlapping**: Each app completely isolated in its own /16 diff --git a/terraform/DEPLOYMENT.md b/terraform/DEPLOYMENT.md new file mode 100644 index 0000000..9be077a --- /dev/null +++ b/terraform/DEPLOYMENT.md @@ -0,0 +1,423 @@ +# Deployment Guide - Drupal Example on AWS + +This guide walks through deploying the Drupal application infrastructure to AWS. + +## Prerequisites + +- ✅ Common infrastructure applied (`iac-common/terraform/infra` - ACM cert + Route 53) +- ✅ Common IAM infrastructure applied (`iac-common/terraform/iam` - creates GitHub Actions IAM user) +- ✅ Terraform Cloud workspace configured with AWS credentials +- ✅ GitHub token configured for Terraform +- ✅ **GitHub Actions AWS credentials configured** (see "Manual GitHub Setup" below) + +## Manual GitHub Setup + +**⚠️ IMPORTANT:** AWS IAM credentials must be manually added to GitHub repository secrets. + +The IAM user `github-actions` is created by the common IAM infrastructure. You need to retrieve the access key and add it to GitHub: + +### Configure GitHub Secrets + +```bash +gh variable set AWS_ACCESS_KEY_ID --body "(IAM access key ID)" +gh secret set AWS_SECRET_ACCESS_KEY --body "(IAM secret access key)" +``` + +## Deployment Steps + +### Step 1: Apply Terraform Infrastructure + +Deploy all AWS resources for the Drupal application: + +```bash +cd /workspaces/drupal-example/terraform +terraform plan # Review what will be created +terraform apply # Create the infrastructure +``` + +**What gets created:** +- VPC with public/private/database subnets across 2 AZs +- NAT Gateways (2 for high availability) +- Security groups for ALB, ECS, RDS, Redis +- RDS MySQL Multi-AZ (db.t4g.micro) +- ElastiCache Redis Multi-AZ (cache.t4g.micro × 2) +- Application Load Balancer with HTTPS +- AWS WAF (OWASP Top 10 protection) +- ECR repository for Docker images +- ECS cluster, task definition, and service +- CloudWatch log groups and alarms +- GitHub Actions secrets and variables +- Route 53 A record: `demo.drupal-example.uceap.net` → ALB + +**Estimated time:** 10-15 minutes + +**⚠️ Note:** The ECS service will fail to start tasks initially because there's no Docker image in ECR yet. This is expected! + +### Step 2: Push Initial Docker Image to ECR + +Choose one of the following options: + +#### Option A: Manual Push (Faster for First Deployment) + +```bash +# Get ECR repository URL from Terraform output +cd /workspaces/drupal-example/terraform +ECR_URL=$(terraform output -raw ecr_repository_url) +AWS_REGION=$(terraform output -raw aws_region) + +# Build Docker image +cd /workspaces/drupal-example +docker build -t drupal-example . + +# Login to ECR +aws ecr get-login-password --region $AWS_REGION | \ + docker login --username AWS --password-stdin $ECR_URL + +# Tag and push +docker tag drupal-example:latest $ECR_URL:latest +docker push $ECR_URL:latest +``` + +#### Option B: GitHub Actions (Automatic) + +```bash +# Commit and push to main branch +git add . +git commit -m "Deploy infrastructure and application" +git push origin main +``` + +This triggers the GitHub Actions workflow (`build_deploy_and_test.yml`) which: +1. Builds the Docker image +2. Pushes to ECR with tags `:latest` and `:${git-sha}` +3. Updates the ECS service to deploy the new image + +### Step 3: Verify ECS Tasks Start + +Wait 2-3 minutes for ECS to pull the image and start tasks: + +```bash +cd /workspaces/drupal-example/terraform + +# Check ECS service status +aws ecs describe-services \ + --cluster $(terraform output -raw ecs_cluster_name) \ + --services $(terraform output -raw ecs_service_name) \ + --region us-west-2 \ + --query 'services[0].{DesiredCount:desiredCount,RunningCount:runningCount,Status:status,Events:events[0:3]}' + +# View ECS task logs in real-time +aws logs tail /ecs/drupal-example --follow --region us-west-2 +``` + +**Expected output:** +- `DesiredCount`: 1 +- `RunningCount`: 1 +- `Status`: ACTIVE +- Recent events showing "service has reached a steady state" + +### Step 4: Access Your Application + +```bash +cd /workspaces/drupal-example/terraform +terraform output app_url +``` + +Visit the URL in your browser: **https://demo.drupal-example.uceap.net** + +- ✅ HTTP requests automatically redirect to HTTPS +- ✅ Valid SSL certificate from ACM +- ✅ Protected by AWS WAF + +--- + +## Verification Checklist + +### DNS Resolution +```bash +dig demo.drupal-example.uceap.net +# Should return ALB IP addresses +``` + +### HTTPS Certificate +```bash +curl -I https://demo.drupal-example.uceap.net +# Should return 200 or 302 with valid SSL +``` + +### Database Connectivity (from ECS container) +```bash +# Get a running task ID +TASK_ID=$(aws ecs list-tasks \ + --cluster $(terraform output -raw ecs_cluster_name) \ + --service-name $(terraform output -raw ecs_service_name) \ + --region us-west-2 \ + --query 'taskArns[0]' \ + --output text | awk -F/ '{print $NF}') + +# Connect to container via SSM Session Manager +aws ecs execute-command \ + --cluster $(terraform output -raw ecs_cluster_name) \ + --task $TASK_ID \ + --container drupal \ + --interactive \ + --command "/bin/bash" \ + --region us-west-2 + +# Inside container, test MySQL connection +mysql -h $MYSQL_HOST -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE -e "SELECT 1;" +``` + +### Redis Connectivity (from ECS container) +```bash +# Inside ECS container (via execute-command above) +redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_AUTH PING +# Should return: PONG +``` + +### CloudWatch Logs +- AWS Console → CloudWatch → Log groups → `/ecs/drupal-example` +- Check for application startup logs and errors + +### WAF Metrics +- AWS Console → WAF & Shield → Web ACLs → `drupal-example-alb-waf` +- Review allowed/blocked request metrics + +--- + +## Making Changes After Deployment + +### Infrastructure Changes + +Edit Terraform configuration files (*.tf), then: + +```bash +cd /workspaces/drupal-example/terraform +terraform plan # Review changes +terraform apply # Apply changes +``` + +### Application Updates (New Docker Image) + +#### Automatic via GitHub Actions (Recommended) + +```bash +# Make code changes, commit, and push to main +git add . +git commit -m "Update application" +git push origin main +``` + +GitHub Actions will automatically: +1. Build new Docker image +2. Push to ECR with `:latest` and `:${git-sha}` tags +3. Update ECS task definition +4. Deploy to ECS service (rolling update) + +#### Manual Deployment + +```bash +# Build and push +cd /workspaces/drupal-example +docker build -t drupal-example . +ECR_URL=$(cd terraform && terraform output -raw ecr_repository_url) +docker tag drupal-example:latest $ECR_URL:latest +docker push $ECR_URL:latest + +# Force new deployment +cd terraform +aws ecs update-service \ + --cluster $(terraform output -raw ecs_cluster_name) \ + --service $(terraform output -raw ecs_service_name) \ + --force-new-deployment \ + --region us-west-2 +``` + +--- + +## Troubleshooting + +### ECS Tasks Not Starting + +**Check service events:** +```bash +aws ecs describe-services \ + --cluster $(terraform output -raw ecs_cluster_name) \ + --services $(terraform output -raw ecs_service_name) \ + --region us-west-2 \ + --query 'services[0].events[0:5]' +``` + +**Common causes:** +- **No Docker image in ECR** → Push image (see Step 2) +- **Task can't pull image** → Check IAM task execution role has ECR permissions +- **Health checks failing** → Check application logs in CloudWatch +- **Resource limits** → Check if CPU/memory limits are appropriate + +### Can't Access Application + +**Check ALB target health:** +```bash +cd /workspaces/drupal-example/terraform +aws elbv2 describe-target-health \ + --target-group-arn $(aws elbv2 describe-target-groups \ + --names ecs-$(terraform output -raw ecs_service_name) \ + --query 'TargetGroups[0].TargetGroupArn' \ + --output text) \ + --region us-west-2 +``` + +**Common causes:** +- **Tasks not healthy** → Check health check path (`/`) returns 200-399 +- **Security group issues** → Verify ALB security group can reach ECS tasks on port 80 +- **DNS not propagated** → Wait 5-10 minutes, check with `dig` +- **Certificate issues** → Verify ACM certificate is issued and validated + +### Database Connection Errors + +**Common causes:** +- **Wrong credentials** → Check AWS Secrets Manager values match RDS +- **Security group** → Verify ECS security group has ingress from RDS security group on port 3306 +- **DNS resolution** → RDS endpoint should resolve inside VPC +- **Drupal not configured** → Check `MYSQL_*` environment variables in task definition + +### Redis Connection Errors + +**Common causes:** +- **Auth token mismatch** → Verify `REDIS_AUTH` secret in Secrets Manager +- **Security group** → Verify ECS security group can reach Redis on port 6379 +- **TLS issues** → Redis has `transit_encryption_enabled = true`, ensure app supports TLS +- **Endpoint wrong** → Verify using `primary_endpoint_address` not individual node + +### High Costs + +If you see unexpected AWS bills: + +**Check running resources:** +```bash +# NAT Gateways (most expensive) +aws ec2 describe-nat-gateways --region us-west-2 + +# Running ECS tasks +aws ecs list-tasks --cluster $(terraform output -raw ecs_cluster_name) --region us-west-2 + +# RDS instances +aws rds describe-db-instances --region us-west-2 + +# ElastiCache clusters +aws elasticache describe-replication-groups --region us-west-2 +``` + +**Cost optimization options:** +- Reduce to 1 NAT Gateway (lose HA): Save ~$32/month +- Use RDS Single-AZ (dev only): Save ~$12/month +- Reduce ECS min_capacity to 0 when not testing: Save ~$29/month per task + +--- + +## Monitoring + +### CloudWatch Alarms + +The following alarms are configured but **not sending notifications** (no SNS topic): + +1. **ECS Task Count** - Alerts if running tasks < minimum +2. **ECS CPU High** - Alerts if CPU > 90% for 15 minutes +3. **RDS CPU High** - Alerts if database CPU > 80% for 15 minutes +4. **ALB Unhealthy Targets** - Alerts if any targets are unhealthy +5. **WAF Blocked Requests** - Alerts if > 100 requests blocked in 5 minutes + +**To enable email notifications:** See `TODO.md` for SNS topic setup instructions. + +### Viewing Logs + +```bash +# ECS application logs (real-time) +aws logs tail /ecs/drupal-example --follow --region us-west-2 + +# WAF logs (security events) +aws logs tail /aws/waf/drupal-example --follow --region us-west-2 + +# Filter logs for errors +aws logs tail /ecs/drupal-example --filter-pattern "ERROR" --region us-west-2 +``` + +### Metrics Dashboard + +View in AWS Console: +- **ECS**: CloudWatch → Container Insights → ECS clusters → drupal-example-cluster +- **RDS**: RDS → Databases → drupal-example-mysql → Monitoring +- **Redis**: ElastiCache → Redis clusters → drupal-example-redis → Metrics +- **ALB**: EC2 → Load Balancers → drupal-example-alb → Monitoring +- **WAF**: WAF & Shield → Web ACLs → drupal-example-alb-waf → Overview + +--- + +## Cleanup / Destroy Infrastructure + +**⚠️ Warning:** This will permanently delete all resources including databases! + +```bash +cd /workspaces/drupal-example/terraform + +# Review what will be destroyed +terraform plan -destroy + +# Destroy all resources +terraform destroy +``` + +**Manual cleanup required:** +- ALB access logs in S3 bucket (bucket will fail to delete if not empty) +- ECR images (delete manually or set lifecycle policy) + +To empty S3 bucket before destroy: +```bash +BUCKET_NAME=$(aws s3 ls | grep drupal-example-alb-logs | awk '{print $3}') +aws s3 rm s3://$BUCKET_NAME --recursive +``` + +--- + +## Next Steps + +### Short-term (1-2 weeks) +- ✅ Monitor CloudWatch logs and metrics +- ✅ Test GitHub Actions deployments by pushing code changes +- ✅ Verify auto-scaling by generating load +- ⏳ Set up SNS notifications for CloudWatch alarms +- ⏳ Test database backups and restore procedures + +### Medium-term (before production) +Review `TODO.md` for production hardening: +- Enable RDS deletion protection +- Require final snapshots on RDS deletion +- Enable TLS for MySQL connections +- Increase log retention periods +- Use semantic versioning for Docker images +- Add monitoring dashboards +- Perform load testing + +--- + +## Support & Documentation + +**Terraform Outputs:** +```bash +cd /workspaces/drupal-example/terraform +terraform output # View all outputs +``` + +**Architecture Diagram:** +See `aws_drupal_infrastructure.png` in the terraform directory + +**Cost Estimate:** +- Minimum (1 ECS task): ~$165-178/month +- Average (2 ECS tasks): ~$194-207/month +- Peak (3 ECS tasks): ~$223-236/month + +**AWS Documentation:** +- [ECS Fargate](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/AWS_Fargate.html) +- [RDS Multi-AZ](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.MultiAZ.html) +- [ElastiCache Redis](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/WhatIs.html) +- [AWS WAF](https://docs.aws.amazon.com/waf/latest/developerguide/waf-chapter.html) diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..3843273 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,233 @@ +# AWS Infrastructure for Drupal Application + +This directory contains Terraform configuration to deploy the Drupal application on AWS using ECS Fargate. + +## Architecture + +The infrastructure consists of: + +- **VPC**: Custom VPC with public, private, and database subnets across 2 availability zones +- **ECS Fargate**: Serverless container orchestration for running Drupal +- **Application Load Balancer**: Distributes traffic to ECS tasks with cross-zone load balancing +- **AWS WAF**: Web Application Firewall protecting against OWASP Top 10 attacks +- **RDS MySQL**: Managed MySQL database (db.t4g.micro) +- **ElastiCache Redis**: Managed Redis cache (cache.t4g.micro) +- **Auto Scaling**: Scales ECS tasks based on CPU and memory utilization (1-3 tasks) +- **CloudWatch**: Centralized logging for ECS tasks and WAF metrics +- **GitHub Actions Integration**: Automatically sets secrets and variables +- **SSM Session Manager**: Secure access to running ECS tasks for debugging + +![AWS Infrastructure Diagram](aws_drupal_infrastructure.png) + +## Prerequisites + +1. **Terraform** >= 1.0 +2. **Terraform Cloud Account** with organization "UCEAP" +3. **Terraform Cloud API Token** (for authentication) +4. **GitHub Token** with repo and workflow permissions + +## Setup + +### 1. Authenticate with Terraform Cloud + +This project uses Terraform Cloud for state management and runs. Authenticate using: + +```bash +terraform login +``` + +### 2. Initialize Terraform + +```bash +cd terraform +terraform init +``` + +### 3. Review and Customize Variables + +Edit `variables.tf` or create `terraform.tfvars` to customize: + +```hcl +name_prefix = "my-drupal-app" +aws_region = "us-west-2" + +# Adjust instance sizes for production +db_instance_class = "db.t4g.small" +redis_node_type = "cache.t4g.small" +ecs_task_cpu = 1024 +ecs_task_memory = 2048 +``` + +## Deployment + +### Plan + +Preview what Terraform will create: + +```bash +terraform plan +``` + +### Apply + +Create the infrastructure: + +```bash +terraform apply +``` + +This will take approximately 10-15 minutes to provision all resources. + +### Get Application URL + +After deployment completes: + +```bash +terraform output alb_url +``` + +Visit the URL to access your Drupal application. + +## Post-Deployment + +### Manual Steps + +Unlike Azure, AWS ECS doesn't require manual post-deployment configuration. However, you may want to: + +1. **Configure DNS**: Point your domain to the ALB DNS name using a CNAME record +2. **Enable HTTPS**: Add an ACM certificate and uncomment the HTTPS listener in `alb.tf` +3. **Enable Multi-AZ for RDS**: Set `multi_az = true` in `rds.tf` for production + +### GitHub Actions Variables + +Terraform automatically sets these secrets/variables in your GitHub repository: + +**Secrets (Secrets Manager ARNs):** +- `AWS_HASH_SALT_SECRET_ARN` - ARN of Drupal hash salt secret +- `AWS_MYSQL_PASSWORD_SECRET_ARN` - ARN of database password secret +- `AWS_REDIS_AUTH_SECRET_ARN` - ARN of Redis auth token secret + +Note: Sensitive values are stored in AWS Secrets Manager and referenced by ARN. The ECS task retrieves values at runtime from Secrets Manager rather than reading plaintext environment variables. + +**Variables:** +- `AWS_MYSQL_HOST` +- `AWS_MYSQL_TCP_PORT` +- `AWS_MYSQL_USER` +- `AWS_MYSQL_DATABASE` +- `AWS_REDIS_HOST` +- `AWS_REDIS_PORT` +- `AWS_ECS_CLUSTER` +- `AWS_ECS_SERVICE` +- `AWS_ECS_TASK_DEFINITION` +- `AWS_REGION` + +Use these in your GitHub Actions workflows to deploy updates. + +## Updating the Application + +To deploy a new version of your Docker image: + +1. Build and push new image to GHCR +2. The GitHub Actions workflow uses exported variables to update the ECS service automatically + +**Note:** AWS CLI is not required locally when using Terraform Cloud. AWS credentials are managed securely in your Terraform Cloud workspace. + +## Monitoring + +### View ECS Logs + +```bash +aws logs tail /ecs/drupal-example --follow --region us-west-2 +``` + +### Check ECS Service Status + +```bash +aws ecs describe-services \ + --cluster $(terraform output -raw ecs_cluster_name) \ + --services $(terraform output -raw ecs_service_name) \ + --region us-west-2 +``` + +### View Running Tasks + +```bash +aws ecs list-tasks \ + --cluster $(terraform output -raw ecs_cluster_name) \ + --region us-west-2 +``` + +## Cost Optimization + +For development/testing environments, consider: + +- Using smaller instance types (already configured with t4g.micro) +- Reducing `ecs_min_capacity` to 0 when not in use +- Setting `deletion_protection = false` on RDS to allow easy cleanup +- Using spot instances (requires switching from Fargate to EC2 launch type) + +Estimated monthly cost: ~$50-80 USD for the minimal configuration + +## Cleanup + +To destroy all resources: + +```bash +terraform destroy +``` + +**Warning**: This will permanently delete all resources including databases. Make sure you have backups! + +## Troubleshooting + +### ECS Tasks Not Starting + +Check the ECS service events: +```bash +aws ecs describe-services \ + --cluster $(terraform output -raw ecs_cluster_name) \ + --services $(terraform output -raw ecs_service_name) \ + --region us-west-2 \ + --query 'services[0].events[0:5]' +``` + +### Database Connection Issues + +1. Verify security group rules allow ECS tasks to access RDS +2. Check database endpoint in task definition +3. Ensure `require_secure_transport` is set to 0 (already configured) + +### Redis Connection Issues + +1. Verify ElastiCache is in the same VPC +2. Check security group allows port 6379 from ECS tasks +3. Ensure auth token is correctly passed to container + +## File Structure + +``` +terraform/ +├── main.tf # Main documentation +├── providers.tf # AWS provider configuration +├── variables.tf # Input variables +├── outputs.tf # Output values +├── vpc.tf # VPC and networking +├── security_groups.tf # Security groups +├── alb.tf # Application Load Balancer +├── waf.tf # AWS WAF rules and protection +├── rds.tf # MySQL database +├── elasticache.tf # Redis cache +├── ecs.tf # ECS cluster and service +├── secrets.tf # AWS Secrets Manager for sensitive data +├── iam.tf # IAM roles and policies +├── cloudwatch.tf # Log groups +├── github.tf # GitHub integration +└── TODO.md # Deferred architecture improvements +``` + +## Support + +For issues or questions: +- Check CloudWatch logs for application errors +- Review ECS service events for deployment issues +- Consult AWS documentation for service-specific problems diff --git a/terraform/TODO.md b/terraform/TODO.md new file mode 100644 index 0000000..2fec0da --- /dev/null +++ b/terraform/TODO.md @@ -0,0 +1,60 @@ +# Terraform Architecture Review - Deferred Tasks + +## VPC & Network +- [ ] Implement VPC Flow Logs for traffic debugging and monitoring + +## Security & Management +- [x] Configure SSM Session Manager for ECS task access + - Note: SSH to private database resources still requires a bastion host or RDS Proxy with SSM + - For now, you can access ECS tasks via: `aws ecs execute-command --cluster --task --container drupal --interactive --command "/bin/bash"` + +## Load Balancer +- [x] Set up ACM certificate for HTTPS (Route53 validation) + - Implemented: Wildcard certificate *.uceap.net in iac-common/terraform/infra/acm.tf + - DNS validation via Route 53 (automatic) + - HTTPS listener enabled in alb.tf with TLS 1.3/1.2 policy + - HTTP listener redirects to HTTPS (301) + - Custom domain configured: demo.drupal-example.uceap.net +- [x] Enable ALB access logs to S3 for audit trail and debugging +- [ ] Enable deletion protection on ALB to prevent accidental deletion (currently false in alb.tf:58) + +## ECS & Containers +- [x] Move secrets from Docker build-args to runtime environment variables + - Removed ARGs from Dockerfile (MYSQL_PASSWORD, REDIS_AUTH, HASH_SALT) + - Moved composer initialize-container from build-time to entrypoint (runtime) + - Removed build-args from GitHub Actions workflow + - Secrets now injected at runtime from AWS Secrets Manager +- [ ] Enforce specific Docker image tags (semantic versioning) instead of :latest for production deployments +- [ ] Change GitHub Secrets for AWS Secret Manager ARNs to GitHub Variables + +## RDS Database +- [ ] Enable deletion protection for production (currently false in rds.tf:40) +- [ ] Require final snapshot on database deletion for data protection (currently skip_final_snapshot = true in rds.tf:43) +- [ ] Enable auto_minor_version_upgrade for automatic security patches (currently false in rds.tf:37) +- [ ] Adjust backup window (3am) and maintenance window (4am Monday) to avoid overlap +- [ ] Enable require_secure_transport = 1 for mandatory TLS database connections (currently 0 in rds.tf:66) +- [ ] Consider read replicas for scaling read-heavy Drupal workloads + +## ElastiCache Redis +- [x] Enable Redis Replication Group (primary + replica) for Multi-AZ failover + - Implemented: aws_elasticache_replication_group with automatic_failover_enabled = true + - Multi-AZ enabled with 2 cache clusters (1 primary + 1 replica) + - At-rest encryption enabled +- [ ] Change apply_immediately to false for safer production deployments (currently true in elasticache.tf:35) + +## GitHub Actions Integration +- [x] Configure GitHub Actions to automatically set secrets and variables + - AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) from common IAM + - Secrets Manager ARNs for database password, Redis auth, Drupal hash salt + - ECS cluster, service, task definition names + - ECR repository URL for image pushes + - Database and Redis connection details +- [x] Remove secrets from Docker build process (completed above in ECS & Containers) + +## Monitoring & Alerting +- [ ] Configure SNS topics and email subscriptions for CloudWatch alarm notifications + - Create SNS topic for production alerts + - Add SNS topic ARN to alarm_actions in cloudwatch.tf (currently empty arrays) + - Current alarms created: ECS task count, ECS CPU, RDS CPU, ALB unhealthy targets, WAF blocked requests +- [ ] Set up log insights queries and dashboards for monitoring trends +- [ ] Configure long-term log archival to cheaper storage (e.g., S3 Glacier for logs older than 90 days) diff --git a/terraform/alb.tf b/terraform/alb.tf new file mode 100644 index 0000000..50f5582 --- /dev/null +++ b/terraform/alb.tf @@ -0,0 +1,133 @@ +# S3 Bucket for ALB Access Logs +resource "aws_s3_bucket" "alb_logs" { + bucket_prefix = "${var.name_prefix}-alb-logs-" + + tags = { + Name = "${var.name_prefix}-alb-logs-bucket" + } +} + +# Block public access to ALB logs bucket +resource "aws_s3_bucket_public_access_block" "alb_logs" { + bucket = aws_s3_bucket.alb_logs.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +# S3 bucket policy to allow ALB to write logs +resource "aws_s3_bucket_policy" "alb_logs" { + bucket = aws_s3_bucket.alb_logs.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${data.aws_elb_service_account.main.id}:root" + } + Action = "s3:PutObject" + Resource = "${aws_s3_bucket.alb_logs.arn}/*" + }, + { + Effect = "Allow" + Principal = { + Service = "elasticloadbalancing.amazonaws.com" + } + Action = "s3:GetBucketAcl" + Resource = aws_s3_bucket.alb_logs.arn + } + ] + }) +} + +# Get the AWS ELB service account for the current region +data "aws_elb_service_account" "main" {} + +# Application Load Balancer +resource "aws_lb" "main" { + name = "${var.name_prefix}-alb" + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.alb.id] + subnets = aws_subnet.public[*].id + + enable_deletion_protection = false + enable_http2 = true + enable_cross_zone_load_balancing = true + + # Enable access logging to S3 + access_logs { + bucket = aws_s3_bucket.alb_logs.id + enabled = true + } + + tags = { + Name = "${var.name_prefix}-alb" + } + + depends_on = [aws_s3_bucket_policy.alb_logs] +} + +# Target Group for ECS Service +resource "aws_lb_target_group" "ecs" { + name_prefix = "ecs-" + port = var.container_port + protocol = "HTTP" + vpc_id = aws_vpc.main.id + target_type = "ip" # Required for Fargate + + health_check { + enabled = true + healthy_threshold = 2 + unhealthy_threshold = 3 + timeout = 5 + interval = 30 + path = "/" + protocol = "HTTP" + matcher = "200-399" + } + + deregistration_delay = 30 + + tags = { + Name = "${var.name_prefix}-ecs-tg" + } + + lifecycle { + create_before_destroy = true + } +} + +# HTTP Listener - Redirects to HTTPS +resource "aws_lb_listener" "http" { + load_balancer_arn = aws_lb.main.arn + port = "80" + protocol = "HTTP" + + default_action { + type = "redirect" + redirect { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } +} + +# HTTPS Listener +resource "aws_lb_listener" "https" { + load_balancer_arn = aws_lb.main.arn + port = "443" + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + certificate_arn = data.terraform_remote_state.common_infra.outputs.uceap_net_wildcard_cert_arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.ecs.arn + } +} diff --git a/terraform/aws_drupal_infrastructure.png b/terraform/aws_drupal_infrastructure.png new file mode 100644 index 0000000..d16710f Binary files /dev/null and b/terraform/aws_drupal_infrastructure.png differ diff --git a/terraform/azure/.terraform.lock.hcl b/terraform/azure/.terraform.lock.hcl deleted file mode 100644 index 776013c..0000000 --- a/terraform/azure/.terraform.lock.hcl +++ /dev/null @@ -1,65 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/azurerm" { - version = "3.117.1" - constraints = "~> 3.0" - hashes = [ - "h1:3c9iOEtBMnHrpJLlhbQ0sCZPWhE/2dvEPcL8KkXAh7w=", - "zh:0c513676836e3c50d004ece7d2624a8aff6faac14b833b96feeac2e4bc2c1c12", - "zh:50ea01ada95bae2f187db9e926e463f45d860767a85ebc59160414e00e76c35d", - "zh:52c2a9edacc06b3f72153f5ef6daca0761c6292158815961fe37f60bc576a3d7", - "zh:618eed2a06b19b1a025b45b05891846d570a6a1cca4d23f4942f5a99e1f747ae", - "zh:61cde5d3165d7e5ec311d5d89486819cd605c1b2d54611b5c97bd4e97dba2762", - "zh:6a873358d5031fc222f5e05f029d1237f3dce8345c767665f393283dfa2627f6", - "zh:afdd80064b2a04da311856feb4ed45f77ff4df6c356e8c2b10afb51fe7e61c70", - "zh:b09113df7e0e8c8959539bd22bae6c39faeb269ba3c4cd948e742f5cf58c35fb", - "zh:d340db7973109761cfc27d52aa02560363337c908b2c99b3628adc5a70a99d5b", - "zh:d5a577226ebc8c65e8f19384878a86acc4b51ede4b4a82d37c3b331b0efcd4a7", - "zh:e2962b147f9e71732df8dbc74940c10d20906f3c003cbfaa1eb9fabbf601a9f0", - "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", - ] -} - -provider "registry.terraform.io/hashicorp/random" { - version = "3.7.1" - constraints = "~> 3.0" - hashes = [ - "h1:/qtweZW2sk0kBNiQM02RvBXmlVdI9oYqRMCyBZ8XA98=", - "zh:3193b89b43bf5805493e290374cdda5132578de6535f8009547c8b5d7a351585", - "zh:3218320de4be943e5812ed3de995946056db86eb8d03aa3f074e0c7316599bef", - "zh:419861805a37fa443e7d63b69fb3279926ccf98a79d256c422d5d82f0f387d1d", - "zh:4df9bd9d839b8fc11a3b8098a604b9b46e2235eb65ef15f4432bde0e175f9ca6", - "zh:5814be3f9c9cc39d2955d6f083bae793050d75c572e70ca11ccceb5517ced6b1", - "zh:63c6548a06de1231c8ee5570e42ca09c4b3db336578ded39b938f2156f06dd2e", - "zh:697e434c6bdee0502cc3deb098263b8dcd63948e8a96d61722811628dce2eba1", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:a0b8e44927e6327852bbfdc9d408d802569367f1e22a95bcdd7181b1c3b07601", - "zh:b7d3af018683ef22794eea9c218bc72d7c35a2b3ede9233b69653b3c782ee436", - "zh:d63b911d618a6fe446c65bfc21e793a7663e934b2fef833d42d3ccd38dd8d68d", - "zh:fa985cd0b11e6d651f47cff3055f0a9fd085ec190b6dbe99bf5448174434cdea", - ] -} - -provider "registry.terraform.io/integrations/github" { - version = "6.6.0" - constraints = "~> 6.0" - hashes = [ - "h1:Fp0RrNe+w167AQkVUWC1WRAsyjhhHN7aHWUky7VkKW8=", - "zh:0b1b5342db6a17de7c71386704e101be7d6761569e03fb3ff1f3d4c02c32d998", - "zh:2fb663467fff76852126b58315d9a1a457e3b04bec51f04bf1c0ddc9dfbb3517", - "zh:4183e557a1dfd413dae90ca4bac37dbbe499eae5e923567371f768053f977800", - "zh:48b2979f88fb55cdb14b7e4c37c44e0dfbc21b7a19686ce75e339efda773c5c2", - "zh:5d803fb06625e0bcf83abb590d4235c117fa7f4aa2168fa3d5f686c41bc529ec", - "zh:6f1dd094cbab36363583cda837d7ca470bef5f8abf9b19f23e9cd8b927153498", - "zh:772edb5890d72b32868f9fdc0a9a1d4f4701d8e7f8acb37a7ac530d053c776e3", - "zh:798f443dbba6610431dcef832047f6917fb5a4e184a3a776c44e6213fb429cc6", - "zh:cc08dfcc387e2603f6dbaff8c236c1254185450d6cadd6bad92879fe7e7dbce9", - "zh:d5e2c8d7f50f91d6847ddce27b10b721bdfce99c1bbab42a68fa271337d73d63", - "zh:e69a0045440c706f50f84a84ff8b1df520ec9bf757de4b8f9959f2ed20c3f440", - "zh:efc5358573a6403cbea3a08a2fcd2407258ac083d9134c641bdcb578966d8bdf", - "zh:f627a255e5809ec2375f79949c79417847fa56b9e9222ea7c45a463eb663f137", - "zh:f7c02f762e4cf1de7f58bde520798491ccdd54a5bd52278d579c146d1d07d4f0", - "zh:fbd1fee2c9df3aa19cf8851ce134dea6e45ea01cb85695c1726670c285797e25", - ] -} diff --git a/terraform/azure/README.md b/terraform/azure/README.md deleted file mode 100644 index ef763ea..0000000 --- a/terraform/azure/README.md +++ /dev/null @@ -1,25 +0,0 @@ -After running `terraform apply`, there are a couple of manual steps remaining: - - -## Turn off TLS requirement for MySQL - -> to be fixed by adding TLS to the Drupal database settings - -```bash -az mysql flexible-server parameter set \ - --resource-group `terraform output -raw resource_group_name` \ - --server-name `terraform output -raw database_name` \ - --name require_secure_transport --value OFF -``` - -## Get Publish Profile from Azure and set in GitHub Actions - -> to be fixed with https://github.com/hashicorp/terraform-provider-azurerm/issues/8739#issuecomment-906662463 -```bash -az webapp deployment list-publishing-profiles \ - --resource-group `terraform output -raw resource_group_name` \ - --name `terraform output -raw app_service_name` --xml | \ - gh secret set AZURE_WEBAPP_PUBLISH_PROFILE \ - --repo UCEAP/drupal-example \ - --app actions -``` diff --git a/terraform/azure/main.tf b/terraform/azure/main.tf deleted file mode 100644 index fc23749..0000000 --- a/terraform/azure/main.tf +++ /dev/null @@ -1,146 +0,0 @@ -resource "random_pet" "resourcegroup_name" { - prefix = var.name_prefix -} - -resource "random_pet" "rediscache_name" { - prefix = var.name_prefix -} - -resource "random_pet" "database_name" { - prefix = var.name_prefix -} - -resource "random_pet" "serviceplan_name" { - prefix = var.name_prefix -} - -resource "random_pet" "webapp_name" { - prefix = var.name_prefix -} - -resource "random_password" "dbadmin_password" { - length = 16 - special = false -} - -resource "random_bytes" "drupal_salt" { - length = 55 -} - -resource "azurerm_resource_group" "rg" { - location = var.resourcegroup_location - name = random_pet.resourcegroup_name.id -} - -resource "azurerm_redis_cache" "rc" { - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name - name = random_pet.rediscache_name.id - sku_name = var.rediscache_sku - family = var.rediscache_family - capacity = var.rediscache_capacity - non_ssl_port_enabled = true -} - -resource "azurerm_redis_firewall_rule" "az" { - resource_group_name = azurerm_resource_group.rg.name - redis_cache_name = azurerm_redis_cache.rc.name - name = "world" - start_ip = "0.0.0.0" - end_ip = "255.255.255.255" -} - -resource "azurerm_mysql_flexible_server" "db" { - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name - name = random_pet.database_name.id - sku_name = var.mysql_sku - zone = 3 # apparently terraform can't handle automatically-assigned zones - administrator_login = var.dbadmin_login - administrator_password = random_password.dbadmin_password.result -} - -resource "azurerm_mysql_flexible_server_firewall_rule" "az" { - resource_group_name = azurerm_resource_group.rg.name - server_name = azurerm_mysql_flexible_server.db.name - name = "azure" - start_ip_address = "0.0.0.0" - end_ip_address = "0.0.0.0" -} - -resource "azurerm_service_plan" "sp" { - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name - name = random_pet.serviceplan_name.id - os_type = "Linux" - sku_name = var.serviceplan_sku -} - -resource "azurerm_linux_web_app" "wa" { - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name - service_plan_id = azurerm_service_plan.sp.id - name = random_pet.webapp_name.id - site_config { - application_stack { - docker_image_name = var.docker_image - docker_registry_url = var.docker_registry - docker_registry_username = var.github_username - docker_registry_password = var.github_token - } - } -} - -resource "github_actions_secret" "salt" { - repository = var.github_repo - secret_name = "AZURE_HASH_SALT" - plaintext_value = random_bytes.drupal_salt.base64 -} - -resource "github_actions_variable" "dbhost" { - repository = var.github_repo - variable_name = "AZURE_MYSQL_HOST" - value = azurerm_mysql_flexible_server.db.fqdn -} - -resource "github_actions_variable" "dbport" { - repository = var.github_repo - variable_name = "AZURE_MYSQL_TCP_PORT" - value = var.db_port -} - -resource "github_actions_variable" "dbuser" { - repository = var.github_repo - variable_name = "AZURE_MYSQL_USER" - value = var.dbadmin_login -} - -resource "github_actions_secret" "dbpass" { - repository = var.github_repo - secret_name = "AZURE_MYSQL_PASSWORD" - plaintext_value = azurerm_mysql_flexible_server.db.administrator_password -} - -resource "github_actions_variable" "dbname" { - repository = var.github_repo - variable_name = "AZURE_MYSQL_DATABASE" - value = var.db_name -} - -resource "github_actions_variable" "redishost" { - repository = var.github_repo - variable_name = "AZURE_REDIS_HOST" - value = azurerm_redis_cache.rc.hostname -} - -resource "github_actions_secret" "redisauth" { - repository = var.github_repo - secret_name = "AZURE_REDIS_AUTH" - plaintext_value = azurerm_redis_cache.rc.primary_access_key -} - -resource "github_actions_variable" "webappname" { - repository = var.github_repo - variable_name = "AZURE_WEBAPP_NAME" - value = azurerm_linux_web_app.wa.name -} \ No newline at end of file diff --git a/terraform/azure/outputs.tf b/terraform/azure/outputs.tf deleted file mode 100644 index 9eaecfd..0000000 --- a/terraform/azure/outputs.tf +++ /dev/null @@ -1,19 +0,0 @@ -output "resource_group_name" { - value = azurerm_resource_group.rg.name -} - -output "redis_cache_name" { - value = azurerm_redis_cache.rc.name -} - -output "database_name" { - value = azurerm_mysql_flexible_server.db.name -} - -output "service_plan_name" { - value = azurerm_service_plan.sp.name -} - -output "app_service_name" { - value = azurerm_linux_web_app.wa.name -} diff --git a/terraform/azure/providers.tf b/terraform/azure/providers.tf deleted file mode 100644 index 46b51dc..0000000 --- a/terraform/azure/providers.tf +++ /dev/null @@ -1,27 +0,0 @@ -terraform { - required_version = ">=1.0" - - required_providers { - azurerm = { - source = "hashicorp/azurerm" - version = "~>3.0" - } - random = { - source = "hashicorp/random" - version = "~>3.0" - } - - github = { - source = "integrations/github" - version = "~> 6.0" - } - } -} - -provider "github" { - owner = "UCEAP" -} - -provider "azurerm" { - features {} -} \ No newline at end of file diff --git a/terraform/azure/variables.tf b/terraform/azure/variables.tf deleted file mode 100644 index 08c8854..0000000 --- a/terraform/azure/variables.tf +++ /dev/null @@ -1,88 +0,0 @@ -variable "name_prefix" { - type = string - default = "drupal-example" - description = "Prefix of the resource group name that's combined with a random ID so name is unique in your Azure subscription." -} - -variable "resourcegroup_location" { - type = string - default = "eastus2" - description = "Location of the resource group." -} - -variable "serviceplan_sku" { - type = string - default = "B1" - description = "The SKU of the Service Plan." -} - -variable "rediscache_sku" { - type = string - default = "Basic" - description = "The SKU of the Redis Cache." -} - -variable "rediscache_family" { - type = string - default = "C" - description = "The family of the Redis Cache." -} - -variable "rediscache_capacity" { - type = number - default = 0 - description = "The capacity of the Redis Cache." -} - -variable "mysql_sku" { - type = string - default = "B_Standard_B1ms" - description = "The SKU of the MySQL Flexible Server." -} - -variable "db_port" { - type = number - default = 3306 - description = "The port of the MySQL Flexible Server." -} - -variable "db_name" { - type = string - default = "drupal" - description = "The name of the MySQL database." -} - -variable "dbadmin_login" { - type = string - default = "madmin" - description = "The administrator login of the MySQL Flexible Server." -} - -variable "docker_image" { - type = string - default = "uceap/drupal-example:latest" - description = "The Docker image name for the Web App." -} - -variable "docker_registry" { - type = string - default = "https://ghcr.io" - description = "The Docker registry URL for the Web App." -} - -variable "github_repo" { - type = string - default = "drupal-example" - description = "The GitHub repository for the Web App." -} - -variable "github_username" { - type = string - default = "uceap-bot" - description = "The GitHub username for the Docker registry." -} - -variable "github_token" { - type = string - description = "The GitHub token for the Docker registry." -} \ No newline at end of file diff --git a/terraform/cloudwatch.tf b/terraform/cloudwatch.tf new file mode 100644 index 0000000..db0d5ab --- /dev/null +++ b/terraform/cloudwatch.tf @@ -0,0 +1,161 @@ +# CloudWatch Log Group for ECS Tasks +resource "aws_cloudwatch_log_group" "ecs" { + name = "/ecs/${var.name_prefix}" + retention_in_days = 7 + + tags = { + Name = "${var.name_prefix}-ecs-logs" + } +} + +# CloudWatch Log Group for WAF Logs +# Logs all WAF activity including blocked requests +resource "aws_cloudwatch_log_group" "waf" { + name = "/aws/waf/${var.name_prefix}" + retention_in_days = 30 # Extended retention for security compliance + + tags = { + Name = "${var.name_prefix}-waf-logs" + } +} + +# CloudWatch Log Group for ALB Access Logs +# Logs all HTTP/HTTPS requests to the Application Load Balancer +resource "aws_cloudwatch_log_group" "alb" { + name = "/aws/alb/${var.name_prefix}" + retention_in_days = 14 + + tags = { + Name = "${var.name_prefix}-alb-logs" + } +} + +# CloudWatch Alarm - ECS Task Count +# Alert if running task count drops below minimum +resource "aws_cloudwatch_metric_alarm" "ecs_task_count" { + alarm_name = "${var.name_prefix}-ecs-task-count" + alarm_description = "Alert when ECS running task count is below minimum" + comparison_operator = "LessThanThreshold" + evaluation_periods = 2 + metric_name = "RunningCount" + namespace = "ECS/ContainerInsights" + period = 300 # 5 minutes + statistic = "Average" + threshold = var.ecs_min_capacity + treat_missing_data = "breaching" + + dimensions = { + ClusterName = aws_ecs_cluster.main.name + ServiceName = aws_ecs_service.app.name + } + + alarm_actions = [] # Add SNS topic ARN here for notifications + + tags = { + Name = "${var.name_prefix}-ecs-task-count-alarm" + } +} + +# CloudWatch Alarm - ECS Task CPU Utilization +# Alert if CPU utilization stays critically high +resource "aws_cloudwatch_metric_alarm" "ecs_cpu_high" { + alarm_name = "${var.name_prefix}-ecs-cpu-high" + alarm_description = "Alert when ECS task CPU utilization is critically high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + metric_name = "CpuUtilized" + namespace = "ECS/ContainerInsights" + period = 300 # 5 minutes + statistic = "Average" + threshold = 90 # Alert if >90% CPU + treat_missing_data = "notBreaching" + + dimensions = { + ClusterName = aws_ecs_cluster.main.name + ServiceName = aws_ecs_service.app.name + } + + alarm_actions = [] # Add SNS topic ARN here for notifications + + tags = { + Name = "${var.name_prefix}-ecs-cpu-high-alarm" + } +} + +# CloudWatch Alarm - RDS CPU Utilization +# Alert if database CPU is consistently high +resource "aws_cloudwatch_metric_alarm" "rds_cpu_high" { + alarm_name = "${var.name_prefix}-rds-cpu-high" + alarm_description = "Alert when RDS CPU utilization is consistently high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + metric_name = "CPUUtilization" + namespace = "AWS/RDS" + period = 300 # 5 minutes + statistic = "Average" + threshold = 80 # Alert if >80% CPU + treat_missing_data = "notBreaching" + + dimensions = { + DBInstanceIdentifier = aws_db_instance.main.id + } + + alarm_actions = [] # Add SNS topic ARN here for notifications + + tags = { + Name = "${var.name_prefix}-rds-cpu-high-alarm" + } +} + +# CloudWatch Alarm - ALB Unhealthy Target Count +# Alert if any targets become unhealthy +resource "aws_cloudwatch_metric_alarm" "alb_unhealthy_targets" { + alarm_name = "${var.name_prefix}-alb-unhealthy-targets" + alarm_description = "Alert when ALB has unhealthy targets" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 2 + metric_name = "UnHealthyHostCount" + namespace = "AWS/ApplicationELB" + period = 60 # 1 minute + statistic = "Average" + threshold = 1 # Alert if any target unhealthy + treat_missing_data = "notBreaching" + + dimensions = { + LoadBalancer = aws_lb.main.arn_suffix + TargetGroup = aws_lb_target_group.ecs.arn_suffix + } + + alarm_actions = [] # Add SNS topic ARN here for notifications + + tags = { + Name = "${var.name_prefix}-alb-unhealthy-targets-alarm" + } +} + +# CloudWatch Alarm - WAF Blocked Requests +# Alert if WAF is blocking excessive requests (potential attack) +resource "aws_cloudwatch_metric_alarm" "waf_blocked_requests" { + alarm_name = "${var.name_prefix}-waf-blocked-requests" + alarm_description = "Alert when WAF blocks excessive requests" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "BlockedRequests" + namespace = "AWS/WAFV2" + period = 300 # 5 minutes + statistic = "Sum" + threshold = 100 # Alert if >100 requests blocked in 5 min + treat_missing_data = "notBreaching" + + dimensions = { + WebACL = aws_wafv2_web_acl.alb.name + Region = var.aws_region + Rule = "ALL" + } + + alarm_actions = [] # Add SNS topic ARN here for notifications + + tags = { + Name = "${var.name_prefix}-waf-blocked-requests-alarm" + } +} diff --git a/terraform/dns.tf b/terraform/dns.tf new file mode 100644 index 0000000..e2c93d3 --- /dev/null +++ b/terraform/dns.tf @@ -0,0 +1,24 @@ +# Data source to reference wildcard certificate from common infrastructure +data "terraform_remote_state" "common_infra" { + backend = "remote" + + config = { + organization = "UCEAP" + workspaces = { + name = "common-infra-production" + } + } +} + +# Route 53 A record for demo.drupal-example.uceap.net pointing to ALB +resource "aws_route53_record" "app" { + zone_id = data.terraform_remote_state.common_infra.outputs.uceap_net_zone_id + name = "demo.drupal-example.uceap.net" + type = "A" + + alias { + name = aws_lb.main.dns_name + zone_id = aws_lb.main.zone_id + evaluate_target_health = true + } +} diff --git a/terraform/ecs.tf b/terraform/ecs.tf new file mode 100644 index 0000000..7530e1d --- /dev/null +++ b/terraform/ecs.tf @@ -0,0 +1,250 @@ +# ECR Repository +resource "aws_ecr_repository" "drupal" { + name = "${var.name_prefix}-drupal" + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = true + } + + encryption_configuration { + encryption_type = "AES256" + } + + tags = { + Name = "${var.name_prefix}-drupal-repo" + } +} + +# ECR Repository Lifecycle Policy (keep last 10 images) +resource "aws_ecr_lifecycle_policy" "drupal" { + repository = aws_ecr_repository.drupal.name + + policy = jsonencode({ + rules = [ + { + rulePriority = 1 + description = "Keep last 10 images" + selection = { + tagStatus = "tagged" + tagPrefixList = ["v"] + countType = "imageCountMoreThan" + countNumber = 10 + } + action = { + type = "expire" + } + }, + { + rulePriority = 2 + description = "Keep last 5 untagged images" + selection = { + tagStatus = "untagged" + countType = "imageCountMoreThan" + countNumber = 5 + } + action = { + type = "expire" + } + } + ] + }) +} + +# Random bytes for Drupal hash salt +resource "random_bytes" "drupal_salt" { + length = 55 +} + +# ECS Cluster +resource "aws_ecs_cluster" "main" { + name = "${var.name_prefix}-cluster" + + setting { + name = "containerInsights" + value = "enabled" + } + + tags = { + Name = "${var.name_prefix}-cluster" + } +} + +# ECS Task Definition +resource "aws_ecs_task_definition" "app" { + family = "${var.name_prefix}-app" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.ecs_task_cpu + memory = var.ecs_task_memory + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + container_definitions = jsonencode([ + { + name = "drupal" + image = "${aws_ecr_repository.drupal.repository_url}:latest" + essential = true + + portMappings = [ + { + containerPort = var.container_port + protocol = "tcp" + } + ] + + environment = [ + { + name = "MYSQL_HOST" + value = aws_db_instance.main.address + }, + { + name = "MYSQL_TCP_PORT" + value = tostring(var.db_port) + }, + { + name = "MYSQL_USER" + value = var.db_username + }, + { + name = "MYSQL_DATABASE" + value = var.db_name + }, + { + name = "REDIS_HOST" + value = aws_elasticache_replication_group.redis.primary_endpoint_address + }, + { + name = "REDIS_PORT" + value = tostring(var.redis_port) + } + ] + + secrets = [ + { + name = "MYSQL_PASSWORD" + valueFrom = aws_secretsmanager_secret.db_password.arn + }, + { + name = "REDIS_AUTH" + valueFrom = aws_secretsmanager_secret.redis_auth.arn + }, + { + name = "HASH_SALT" + valueFrom = aws_secretsmanager_secret.drupal_salt.arn + } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.ecs.name + "awslogs-region" = var.aws_region + "awslogs-stream-prefix" = "drupal" + } + } + + healthCheck = { + command = ["CMD-SHELL", "curl -f http://localhost/ || exit 1"] + interval = 30 + timeout = 5 + retries = 3 + startPeriod = 60 + } + } + ]) + + tags = { + Name = "${var.name_prefix}-task-def" + } +} + +# ECS Service +resource "aws_ecs_service" "app" { + name = "${var.name_prefix}-service" + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.app.arn + desired_count = var.ecs_desired_count + launch_type = "FARGATE" + + network_configuration { + subnets = aws_subnet.private[*].id + security_groups = [aws_security_group.ecs_tasks.id] + assign_public_ip = false + } + + load_balancer { + target_group_arn = aws_lb_target_group.ecs.arn + container_name = "drupal" + container_port = var.container_port + } + + # Enable SSM Session Manager access to running tasks + enable_execute_command = true + + # Allow external changes without Terraform plan difference + lifecycle { + ignore_changes = [desired_count] + } + + # Wait for ALB and secrets to be ready + depends_on = [ + aws_lb_listener.http, + aws_lb_listener.https, + aws_iam_role_policy_attachment.ecs_task_execution, + aws_iam_role_policy_attachment.ecs_task_execution_ssm, + aws_iam_role_policy.ecs_task_execution_secrets, + aws_secretsmanager_secret_version.db_password, + aws_secretsmanager_secret_version.redis_auth, + aws_secretsmanager_secret_version.drupal_salt + ] + + tags = { + Name = "${var.name_prefix}-service" + } +} + +# Auto Scaling Target +resource "aws_appautoscaling_target" "ecs" { + max_capacity = var.ecs_max_capacity + min_capacity = var.ecs_min_capacity + resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.app.name}" + scalable_dimension = "ecs:service:DesiredCount" + service_namespace = "ecs" +} + +# Auto Scaling Policy - CPU-based +resource "aws_appautoscaling_policy" "ecs_cpu" { + name = "${var.name_prefix}-cpu-autoscaling" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.ecs.resource_id + scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension + service_namespace = aws_appautoscaling_target.ecs.service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageCPUUtilization" + } + target_value = 70.0 + scale_in_cooldown = 300 + scale_out_cooldown = 60 + } +} + +# Auto Scaling Policy - Memory-based +resource "aws_appautoscaling_policy" "ecs_memory" { + name = "${var.name_prefix}-memory-autoscaling" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.ecs.resource_id + scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension + service_namespace = aws_appautoscaling_target.ecs.service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageMemoryUtilization" + } + target_value = 80.0 + scale_in_cooldown = 300 + scale_out_cooldown = 60 + } +} diff --git a/terraform/elasticache.tf b/terraform/elasticache.tf new file mode 100644 index 0000000..5cede91 --- /dev/null +++ b/terraform/elasticache.tf @@ -0,0 +1,67 @@ +# Random auth token for Redis +resource "random_password" "redis_auth_token" { + length = 32 + special = false +} + +# ElastiCache Redis Replication Group (Multi-AZ with automatic failover) +resource "aws_elasticache_replication_group" "redis" { + replication_group_id = "${var.name_prefix}-redis" + description = "Redis cluster for ${var.name_prefix} with Multi-AZ failover" + engine = "redis" + engine_version = var.redis_engine_version + node_type = var.redis_node_type + port = var.redis_port + parameter_group_name = aws_elasticache_parameter_group.redis.name + subnet_group_name = aws_elasticache_subnet_group.main.name + security_group_ids = [aws_security_group.redis.id] + + # Multi-AZ configuration + automatic_failover_enabled = true + multi_az_enabled = true + num_cache_clusters = 2 # 1 primary + 1 replica across AZs + + # Security + transit_encryption_enabled = true + auth_token = random_password.redis_auth_token.result + at_rest_encryption_enabled = true + + # Maintenance and backup + maintenance_window = "sun:05:00-sun:06:00" + snapshot_retention_limit = 5 + snapshot_window = "03:00-04:00" + + # Apply changes immediately (set to false for production) + apply_immediately = true + + tags = { + Name = "${var.name_prefix}-redis" + } +} + +# ElastiCache Parameter Group +resource "aws_elasticache_parameter_group" "redis" { + name = "${var.name_prefix}-redis" + family = "redis7" + description = "Custom parameter group for ${var.name_prefix} Redis" + + # Enable notifications for keyspace events (useful for Drupal) + parameter { + name = "notify-keyspace-events" + value = "Ex" + } + + # Set max memory policy to evict least recently used keys + parameter { + name = "maxmemory-policy" + value = "allkeys-lru" + } + + tags = { + Name = "${var.name_prefix}-redis-params" + } + + lifecycle { + create_before_destroy = true + } +} diff --git a/terraform/github.tf b/terraform/github.tf new file mode 100644 index 0000000..0a6a134 --- /dev/null +++ b/terraform/github.tf @@ -0,0 +1,108 @@ +# NOTE: AWS IAM credentials for GitHub Actions must be manually configured +# in GitHub repository settings. See DEPLOYMENT.md for setup instructions. +# +# Required GitHub Actions secrets to set manually: +# - AWS_ACCESS_KEY_ID: IAM user access key ID with ECR and ECS permissions +# - AWS_SECRET_ACCESS_KEY: IAM user secret access key +# +# These credentials are no longer managed by Terraform due to security best practices +# and to avoid dependency on Terraform Cloud workspace sharing. + +# GitHub Actions Secrets - Secrets Manager ARNs (not plaintext values) +resource "github_actions_secret" "db_password_secret_arn" { + repository = var.github_repo + secret_name = "AWS_MYSQL_PASSWORD_SECRET_ARN" + plaintext_value = aws_secretsmanager_secret.db_password.arn +} + +resource "github_actions_secret" "redis_auth_secret_arn" { + repository = var.github_repo + secret_name = "AWS_REDIS_AUTH_SECRET_ARN" + plaintext_value = aws_secretsmanager_secret.redis_auth.arn +} + +resource "github_actions_secret" "drupal_salt_secret_arn" { + repository = var.github_repo + secret_name = "AWS_HASH_SALT_SECRET_ARN" + plaintext_value = aws_secretsmanager_secret.drupal_salt.arn +} + +# GitHub Actions Variables +resource "github_actions_variable" "db_host" { + repository = var.github_repo + variable_name = "AWS_MYSQL_HOST" + value = aws_db_instance.main.address +} + +resource "github_actions_variable" "db_port" { + repository = var.github_repo + variable_name = "AWS_MYSQL_TCP_PORT" + value = tostring(var.db_port) +} + +resource "github_actions_variable" "db_user" { + repository = var.github_repo + variable_name = "AWS_MYSQL_USER" + value = var.db_username +} + +resource "github_actions_variable" "db_name" { + repository = var.github_repo + variable_name = "AWS_MYSQL_DATABASE" + value = var.db_name +} + +resource "github_actions_variable" "redis_host" { + repository = var.github_repo + variable_name = "AWS_REDIS_HOST" + value = aws_elasticache_replication_group.redis.primary_endpoint_address +} + +resource "github_actions_variable" "redis_port" { + repository = var.github_repo + variable_name = "AWS_REDIS_PORT" + value = tostring(var.redis_port) +} + +resource "github_actions_variable" "ecs_cluster" { + repository = var.github_repo + variable_name = "AWS_ECS_CLUSTER" + value = aws_ecs_cluster.main.name +} + +resource "github_actions_variable" "ecs_service" { + repository = var.github_repo + variable_name = "AWS_ECS_SERVICE" + value = aws_ecs_service.app.name +} + +resource "github_actions_variable" "ecs_task_definition" { + repository = var.github_repo + variable_name = "AWS_ECS_TASK_DEFINITION" + value = aws_ecs_task_definition.app.family +} + +resource "github_actions_variable" "aws_region" { + repository = var.github_repo + variable_name = "AWS_REGION" + value = var.aws_region +} + +# ECR Variables for pushing images +resource "github_actions_variable" "ecr_repository_url" { + repository = var.github_repo + variable_name = "ECR_REPOSITORY_URL" + value = aws_ecr_repository.drupal.repository_url +} + +resource "github_actions_variable" "ecr_repository_name" { + repository = var.github_repo + variable_name = "ECR_REPOSITORY_NAME" + value = aws_ecr_repository.drupal.name +} + +resource "github_actions_variable" "aws_account_id" { + repository = var.github_repo + variable_name = "AWS_ACCOUNT_ID" + value = var.aws_account_id +} diff --git a/terraform/iam.tf b/terraform/iam.tf new file mode 100644 index 0000000..8d742fe --- /dev/null +++ b/terraform/iam.tf @@ -0,0 +1,117 @@ +# IAM Role for ECS Task Execution +# Used by the ECS agent to pull images and manage task resources +# Separate from the task role which is used by the application itself +resource "aws_iam_role" "ecs_task_execution" { + name_prefix = "${var.name_prefix}-ecs-exec-" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + } + ] + }) + + tags = { + Name = "${var.name_prefix}-ecs-execution-role" + } +} + +# AWS managed policy for ECS task execution +# Includes permissions for: +# - Pulling Docker images from ECR +# - Writing container logs to CloudWatch +resource "aws_iam_role_policy_attachment" "ecs_task_execution" { + role = aws_iam_role.ecs_task_execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +# IAM Role for ECS Task (application runtime permissions) +# Used by the Drupal application running inside the container +# This role should have minimal permissions required for the application to function +resource "aws_iam_role" "ecs_task" { + name_prefix = "${var.name_prefix}-ecs-task-" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + } + ] + }) + + tags = { + Name = "${var.name_prefix}-ecs-task-role" + } +} + +# Policy for ECS task runtime permissions +# Allows the Drupal application container to access AWS services +resource "aws_iam_role_policy" "ecs_task" { + name_prefix = "task-" + role = aws_iam_role.ecs_task.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + # Allow SSM Session Manager for interactive access to running tasks + # Required for: aws ecs execute-command to debug/troubleshoot container + Effect = "Allow" + Action = [ + "ssmmessages:AcknowledgeMessage", + "ssmmessages:GetEndpoint", + "ssmmessages:GetMessages", + "ec2messages:AcknowledgeMessage", + "ec2messages:GetEndpoint", + "ec2messages:GetMessages" + ] + Resource = "*" + } + ] + }) +} + +# IAM Role for ECS Task Execution to support SSM +resource "aws_iam_role_policy_attachment" "ecs_task_execution_ssm" { + role = aws_iam_role.ecs_task_execution.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +# IAM Policy for ECS Task Execution to access Secrets Manager +# Allows the ECS agent to retrieve secrets from AWS Secrets Manager +# and inject them into the container at runtime +# Restricted to only the 3 secrets used by this application (least privilege) +resource "aws_iam_role_policy" "ecs_task_execution_secrets" { + name_prefix = "ecs-task-exec-secrets-" + role = aws_iam_role.ecs_task_execution.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + # Allow retrieving specific secrets from Secrets Manager + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ] + Resource = [ + aws_secretsmanager_secret.db_password.arn, + aws_secretsmanager_secret.redis_auth.arn, + aws_secretsmanager_secret.drupal_salt.arn + ] + } + ] + }) +} diff --git a/terraform/infrastructure_diagram.py b/terraform/infrastructure_diagram.py new file mode 100644 index 0000000..99f6b82 --- /dev/null +++ b/terraform/infrastructure_diagram.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +""" +AWS Drupal Infrastructure Diagram Generator +Generates a Graphviz diagram from Terraform configuration +""" + +from graphviz import Digraph + +def create_aws_infrastructure_diagram(): + """Create a comprehensive diagram of the AWS Drupal infrastructure""" + + # Create the main graph with custom styling + dot = Digraph( + 'AWS_Drupal_Infrastructure', + comment='Drupal on AWS - ECS Fargate Architecture', + format='png' + ) + + # Global graph attributes + dot.attr( + rankdir='TB', + splines='polyline', + nodesep='0.6', + ranksep='1.0', + fontname='Arial', + fontsize='14', + bgcolor='white', + compound='true', + newrank='true' + ) + + # Node styling defaults + dot.attr('node', + shape='box', + style='rounded,filled', + fontname='Arial', + fontsize='11', + margin='0.3,0.2' + ) + + # === INTERNET & ENTRY POINT === + dot.node('internet', 'Internet\n🌐', + shape='ellipse', + fillcolor='#E3F2FD', + color='#1976D2', + fontsize='13', + penwidth='2') + + # === REGIONAL SERVICES (Above VPC) === + with dot.subgraph(name='cluster_regional') as regional: + regional.attr(label='AWS Region US-West-2', + fontsize='13', + style='dashed', + color='#616161', + penwidth='2') + + # WAF (first line of defense) + regional.node('waf', 'AWS WAF\n(OWASP Top 10 + Rate Limiting)', + fillcolor='#EF9A9A', + color='#C62828', + shape='hexagon', + penwidth='2') + + # === VPC === + with regional.subgraph(name='cluster_vpc') as vpc: + vpc.attr(label='VPC (10.X.0.0/16)', + fontsize='14', + style='solid', + color='#1976D2', + penwidth='3', + bgcolor='#F5F5F5') + + # Internet Gateway (at VPC boundary) + vpc.node('igw', 'Internet Gateway', + fillcolor='#64B5F6', + color='#1565C0', + penwidth='2') + + # === AVAILABILITY ZONE 1 === + with vpc.subgraph(name='cluster_az1') as az1: + az1.attr(label='Availability Zone 1 (us-west-2a)', + fontsize='12', + style='solid', + color='#FF6F00', + penwidth='2') + + # Public Subnet with NAT Gateway at top + with az1.subgraph(name='cluster_public_az1') as pub_az1: + pub_az1.attr(label='Public Subnet (10.X.0.0/24)', + fontsize='10', + style='filled', + fillcolor='#E8F4F8', + color='#1E88E5') + pub_az1.node('nat_az1', 'NAT Gateway\n(Elastic IP)', + fillcolor='#90CAF9', + color='#1565C0') + pub_az1.node('alb_az1', 'Application Load Balancer\n(Spans AZs)', + fillcolor='#64B5F6', + color='#1565C0', + shape='box3d', + penwidth='2') + + # Private Subnet AZ1 + with az1.subgraph(name='cluster_private_az1') as priv_az1: + priv_az1.attr(label='Private Subnet (10.X.10.0/24)', + fontsize='10', + style='filled', + fillcolor='#F3E5F5', + color='#8E24AA') + priv_az1.node('ecs_task_az1', 'ECS Tasks\n(Drupal Containers)', + fillcolor='#CE93D8', + color='#6A1B9A', + shape='component', + penwidth='2') + + # Database Subnet AZ1 + with az1.subgraph(name='cluster_db_az1') as db_az1: + db_az1.attr(label='Database Subnet (10.X.20.0/24)', + fontsize='10', + style='filled', + fillcolor='#FFF3E0', + color='#F57C00') + db_az1.node('rds_az1', 'RDS MySQL\n(Primary)', + fillcolor='#FFB74D', + color='#E65100', + shape='cylinder', + penwidth='2') + db_az1.node('redis_az1', 'ElastiCache\nRedis Node', + fillcolor='#FFB74D', + color='#E65100', + shape='cylinder', + penwidth='2') + + # === AVAILABILITY ZONE 2 === + with vpc.subgraph(name='cluster_az2') as az2: + az2.attr(label='Availability Zone 2 (us-west-2b)', + fontsize='12', + style='solid', + color='#FF6F00', + penwidth='2') + + # Public Subnet with NAT Gateway at top + with az2.subgraph(name='cluster_public_az2') as pub_az2: + pub_az2.attr(label='Public Subnet (10.X.1.0/24)', + fontsize='10', + style='filled', + fillcolor='#E8F4F8', + color='#1E88E5') + pub_az2.node('nat_az2', 'NAT Gateway\n(Elastic IP)', + fillcolor='#90CAF9', + color='#1565C0') + pub_az2.node('alb_az2', 'Application Load Balancer\n(Spans AZs)', + fillcolor='#64B5F6', + color='#1565C0', + shape='box3d', + style='rounded,filled,dashed',) + + # Private Subnet AZ2 + with az2.subgraph(name='cluster_private_az2') as priv_az2: + priv_az2.attr(label='Private Subnet (10.X.11.0/24)', + fontsize='10', + style='filled', + fillcolor='#F3E5F5', + color='#8E24AA') + priv_az2.node('ecs_task_az2', 'ECS Tasks\n(Drupal Containers)', + fillcolor='#CE93D8', + color='#6A1B9A', + shape='component', + penwidth='2') + + # Database Subnet AZ2 + with az2.subgraph(name='cluster_db_az2') as db_az2: + db_az2.attr(label='Database Subnet (10.X.21.0/24)', + fontsize='10', + style='filled', + fillcolor='#FFF3E0', + color='#F57C00') + db_az2.node('rds_az2', 'RDS MySQL\n(Standby)', + fillcolor='#FFB74D', + color='#E65100', + shape='cylinder', + style='rounded,filled,dashed') + db_az2.node('redis_az2', 'ElastiCache\n(Spans AZs)', + fillcolor='#FFB74D', + color='#E65100', + shape='cylinder', + style='rounded,filled,dashed') + + + # === FORCE VERTICAL ORDERING === + # Main vertical flow: Internet -> WAF -> IGW -> ALB + with dot.subgraph() as s: + s.attr(rank='same') + s.node('internet') + + with dot.subgraph() as s: + s.attr(rank='same') + s.node('waf') + + with dot.subgraph() as s: + s.node('igw') + + with dot.subgraph() as s: + s.attr(rank='same') + s.node('alb_az1') + s.node('alb_az2') + + with dot.subgraph() as s: + s.attr(rank='same') + s.node('nat_az1') + s.node('nat_az2') + + with dot.subgraph() as s: + s.attr(rank='same') + s.node('ecs_task_az1') + s.node('ecs_task_az2') + + with dot.subgraph() as s: + s.attr(rank='same') + s.node('redis_az1') + s.node('redis_az2') + + with dot.subgraph() as s: + s.attr(rank='same') + s.node('rds_az1') + s.node('rds_az2') + + # # Add invisible edges within each AZ to force vertical ordering + dot.edge('alb_az1', 'nat_az1', style='invis') + dot.edge('alb_az2', 'nat_az2', style='invis') + dot.edge('redis_az1', 'rds_az1', style='invis') + dot.edge('redis_az2', 'rds_az2', style='invis') + + # === PRIMARY TRAFFIC FLOW (Main Request Path) === + # Internet -> WAF -> IGW -> ALB + dot.edge('internet', 'waf', color='#1976D2', style='bold', penwidth='3') + dot.edge('waf', 'igw', color='#1976D2', penwidth='3') + dot.edge('igw', 'alb_az1', color='#1E88E5', penwidth='2.5') + dot.edge('igw', 'alb_az2', color='#1E88E5', penwidth='2.5') + # ALB -> ECS Tasks (Load balanced) + dot.edge('alb_az1', 'ecs_task_az1', color='#8E24AA', penwidth='2.5') + dot.edge('alb_az2', 'ecs_task_az2', color='#8E24AA', penwidth='2.5') + + # ECS Tasks -> Backend Services + dot.edge('ecs_task_az1', 'rds_az1', color='#F57C00', penwidth='2') + dot.edge('ecs_task_az1', 'redis_az1', color='#F57C00', penwidth='2') + dot.edge('ecs_task_az2', 'rds_az1', color='#F57C00', penwidth='2') + dot.edge('ecs_task_az2', 'redis_az2', color='#F57C00', penwidth='2') + + # === OUTBOUND TRAFFIC (From ECS to Internet) === + dot.edge('ecs_task_az1', 'nat_az1', label='outbound', color='#7B1FA2', style='dotted', penwidth='1.5', fontsize='9') + dot.edge('ecs_task_az2', 'nat_az2', label='outbound', color='#7B1FA2', style='dotted', penwidth='1.5', fontsize='9') + dot.edge('nat_az1', 'igw', color='#1E88E5', style='dotted', penwidth='1.5') + dot.edge('nat_az2', 'igw', color='#1E88E5', style='dotted', penwidth='1.5') + + # === HIGH AVAILABILITY === + # RDS Multi-AZ Replication + dot.edge('redis_az1', 'redis_az2', label='same node', color='#EF6C00', style='dashed', dir='both', penwidth='2', fontsize='9') + dot.edge('rds_az1', 'rds_az2', label='synchronous replication', color='#EF6C00', style='dashed', dir='both', penwidth='2', fontsize='9') + + return dot + +if __name__ == '__main__': + # Generate the diagram + diagram = create_aws_infrastructure_diagram() + + # Render to file + output_path = diagram.render('aws_drupal_infrastructure', cleanup=True) + print(f"✓ Diagram generated: {output_path}") + print("✓ Infrastructure visualization complete!") diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..55eee92 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,30 @@ +# Drupal Application Infrastructure on AWS +# +# This Terraform configuration deploys a Drupal application on AWS using: +# - ECS Fargate for container orchestration +# - RDS MySQL for the database +# - ElastiCache Redis for caching +# - Application Load Balancer for traffic distribution +# - VPC with public/private/database subnets across 2 availability zones +# +# The infrastructure is organized into modular files: +# - vpc.tf: VPC, subnets, route tables, NAT gateways +# - security_groups.tf: Security groups for ALB, ECS, RDS, Redis +# - alb.tf: Application Load Balancer and target groups +# - rds.tf: RDS MySQL database +# - elasticache.tf: ElastiCache Redis cluster +# - ecs.tf: ECS cluster, task definition, service, auto-scaling +# - iam.tf: IAM roles and policies for ECS +# - cloudwatch.tf: CloudWatch log groups +# - github.tf: GitHub Actions secrets and variables +# - outputs.tf: Terraform outputs +# - variables.tf: Input variables +# - providers.tf: Provider configuration +# +# Usage: +# terraform init +# terraform plan +# terraform apply +# +# After applying, access your application at the ALB DNS name: +# terraform output alb_url diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..afa29a1 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,98 @@ +# VPC Outputs +output "vpc_id" { + description = "ID of the VPC" + value = aws_vpc.main.id +} + +# Load Balancer Outputs +output "alb_dns_name" { + description = "DNS name of the Application Load Balancer" + value = aws_lb.main.dns_name +} + +output "alb_url" { + description = "URL of the application (via ALB DNS)" + value = "https://${aws_lb.main.dns_name}" +} + +output "app_url" { + description = "URL of the application (via custom domain)" + value = "https://${aws_route53_record.app.name}" +} + +# Database Outputs +output "db_endpoint" { + description = "RDS MySQL endpoint" + value = aws_db_instance.main.endpoint +} + +output "db_address" { + description = "RDS MySQL address" + value = aws_db_instance.main.address +} + +output "db_name" { + description = "Database name" + value = aws_db_instance.main.db_name +} + +# Redis Outputs +output "redis_endpoint" { + description = "ElastiCache Redis primary endpoint" + value = aws_elasticache_replication_group.redis.primary_endpoint_address +} + +output "redis_port" { + description = "ElastiCache Redis port" + value = var.redis_port +} + +# ECS Outputs +output "ecs_cluster_name" { + description = "Name of the ECS cluster" + value = aws_ecs_cluster.main.name +} + +output "ecs_service_name" { + description = "Name of the ECS service" + value = aws_ecs_service.app.name +} + +output "ecs_task_definition_family" { + description = "Family name of the ECS task definition" + value = aws_ecs_task_definition.app.family +} + +# Sensitive Outputs (use with caution) +output "db_username" { + description = "Database username" + value = var.db_username +} + +output "db_password" { + description = "Database password" + value = random_password.db_password.result + sensitive = true +} + +output "redis_auth_token" { + description = "Redis auth token" + value = random_password.redis_auth_token.result + sensitive = true +} + +# ECR Outputs +output "ecr_repository_url" { + description = "URL of the ECR repository for pushing Docker images" + value = aws_ecr_repository.drupal.repository_url +} + +output "ecr_repository_name" { + description = "Name of the ECR repository" + value = aws_ecr_repository.drupal.name +} + +output "ecr_registry_id" { + description = "The registry ID where the repository was created" + value = aws_ecr_repository.drupal.registry_id +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000..946b1e7 --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,32 @@ +terraform { + cloud { + organization = "UCEAP" + + workspaces { + name = "drupal-example-demo" + } + } + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + github = { + source = "integrations/github" + version = "~> 6.0" + } + } +} + +provider "aws" { + region = var.aws_region +} + +provider "github" { + owner = var.github_owner +} \ No newline at end of file diff --git a/terraform/rds.tf b/terraform/rds.tf new file mode 100644 index 0000000..b395512 --- /dev/null +++ b/terraform/rds.tf @@ -0,0 +1,82 @@ +# Random password for database +resource "random_password" "db_password" { + length = 16 + special = false +} + +# RDS MySQL Instance +resource "aws_db_instance" "main" { + identifier = "${var.name_prefix}-mysql" + engine = "mysql" + engine_version = var.db_engine_version + instance_class = var.db_instance_class + + allocated_storage = var.db_allocated_storage + storage_type = "gp3" + storage_encrypted = true + max_allocated_storage = 100 # Enable storage autoscaling + + db_name = var.db_name + username = var.db_username + password = random_password.db_password.result + port = var.db_port + + db_subnet_group_name = aws_db_subnet_group.main.name + vpc_security_group_ids = [aws_security_group.rds.id] + publicly_accessible = false + + # Multi-AZ deployment for high availability + multi_az = true + + # Backup configuration + backup_retention_period = 7 + backup_window = "03:00-04:00" + maintenance_window = "mon:04:00-mon:05:00" + + # Disable automated minor version upgrades + auto_minor_version_upgrade = false + + # Enable deletion protection for production + deletion_protection = false + + # Skip final snapshot for non-production (set to false for production) + skip_final_snapshot = true + + # Enable enhanced monitoring (optional) + enabled_cloudwatch_logs_exports = ["error", "general", "slowquery"] + + # Parameter group for MySQL configuration + parameter_group_name = aws_db_parameter_group.main.name + + tags = { + Name = "${var.name_prefix}-mysql" + } +} + +# DB Parameter Group for MySQL configuration +resource "aws_db_parameter_group" "main" { + name_prefix = "${var.name_prefix}-mysql-" + family = "mysql8.0" + description = "Custom parameter group for ${var.name_prefix}" + + # TLS/SSL not required (matching your Azure setup) + # To enable TLS, change this to ON and configure Drupal with SSL + parameter { + name = "require_secure_transport" + value = "0" + } + + # Recommended performance parameters + parameter { + name = "max_connections" + value = "100" + } + + tags = { + Name = "${var.name_prefix}-mysql-params" + } + + lifecycle { + create_before_destroy = true + } +} diff --git a/terraform/secrets.tf b/terraform/secrets.tf new file mode 100644 index 0000000..b96e2a4 --- /dev/null +++ b/terraform/secrets.tf @@ -0,0 +1,50 @@ +# AWS Secrets Manager - Database Password +resource "aws_secretsmanager_secret" "db_password" { + name_prefix = "${var.name_prefix}-db-password-" + description = "RDS MySQL admin password for ${var.name_prefix}" + recovery_window_in_days = 7 + + tags = { + Name = "${var.name_prefix}-db-password" + } +} + +resource "aws_secretsmanager_secret_version" "db_password" { + secret_id = aws_secretsmanager_secret.db_password.id + secret_string = random_password.db_password.result + version_stages = ["AWSCURRENT"] +} + +# AWS Secrets Manager - Redis Auth Token +resource "aws_secretsmanager_secret" "redis_auth" { + name_prefix = "${var.name_prefix}-redis-auth-" + description = "ElastiCache Redis auth token for ${var.name_prefix}" + recovery_window_in_days = 7 + + tags = { + Name = "${var.name_prefix}-redis-auth" + } +} + +resource "aws_secretsmanager_secret_version" "redis_auth" { + secret_id = aws_secretsmanager_secret.redis_auth.id + secret_string = random_password.redis_auth_token.result + version_stages = ["AWSCURRENT"] +} + +# AWS Secrets Manager - Drupal Hash Salt +resource "aws_secretsmanager_secret" "drupal_salt" { + name_prefix = "${var.name_prefix}-drupal-salt-" + description = "Drupal hash salt for ${var.name_prefix}" + recovery_window_in_days = 7 + + tags = { + Name = "${var.name_prefix}-drupal-salt" + } +} + +resource "aws_secretsmanager_secret_version" "drupal_salt" { + secret_id = aws_secretsmanager_secret.drupal_salt.id + secret_string = random_bytes.drupal_salt.base64 + version_stages = ["AWSCURRENT"] +} diff --git a/terraform/security_groups.tf b/terraform/security_groups.tf new file mode 100644 index 0000000..e148297 --- /dev/null +++ b/terraform/security_groups.tf @@ -0,0 +1,133 @@ +# Security Group for Application Load Balancer +resource "aws_security_group" "alb" { + name_prefix = "${var.name_prefix}-alb-" + description = "Security group for Application Load Balancer" + vpc_id = aws_vpc.main.id + + ingress { + description = "HTTP from Internet" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "HTTPS from Internet" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + description = "All outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.name_prefix}-alb-sg" + } + + lifecycle { + create_before_destroy = true + } +} + +# Security Group for ECS Tasks +resource "aws_security_group" "ecs_tasks" { + name_prefix = "${var.name_prefix}-ecs-tasks-" + description = "Security group for ECS tasks" + vpc_id = aws_vpc.main.id + + ingress { + description = "Allow traffic from ALB" + from_port = var.container_port + to_port = var.container_port + protocol = "tcp" + security_groups = [aws_security_group.alb.id] + } + + egress { + description = "All outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.name_prefix}-ecs-tasks-sg" + } + + lifecycle { + create_before_destroy = true + } +} + +# Security Group for RDS MySQL +resource "aws_security_group" "rds" { + name_prefix = "${var.name_prefix}-rds-" + description = "Security group for RDS MySQL" + vpc_id = aws_vpc.main.id + + ingress { + description = "MySQL from ECS tasks" + from_port = var.db_port + to_port = var.db_port + protocol = "tcp" + security_groups = [aws_security_group.ecs_tasks.id] + } + + egress { + description = "Deny all outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = [] + prefix_list_ids = [] + } + + tags = { + Name = "${var.name_prefix}-rds-sg" + } + + lifecycle { + create_before_destroy = true + } +} + +# Security Group for ElastiCache Redis +resource "aws_security_group" "redis" { + name_prefix = "${var.name_prefix}-redis-" + description = "Security group for ElastiCache Redis" + vpc_id = aws_vpc.main.id + + ingress { + description = "Redis from ECS tasks" + from_port = var.redis_port + to_port = var.redis_port + protocol = "tcp" + security_groups = [aws_security_group.ecs_tasks.id] + } + + egress { + description = "Deny all outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = [] + prefix_list_ids = [] + } + + tags = { + Name = "${var.name_prefix}-redis-sg" + } + + lifecycle { + create_before_destroy = true + } +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..f43e884 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,139 @@ +variable "aws_account_id" { + description = "AWS account ID" + type = string +} + +variable "aws_region" { + description = "AWS region to deploy resources (e.g. us-west-2)" + type = string + default = "us-west-2" +} + +variable "name_prefix" { + description = "Prefix for resource names" + type = string + default = "drupal-example" +} + +variable "availability_zones" { + description = "Availability zones to use" + type = list(string) + default = ["us-west-2a", "us-west-2b"] +} + +# Network Configuration +variable "app_number" { + description = "App CIDR octet (10.{app_number}.0.0/16). Range: 101-254" + type = number + default = 101 + validation { + condition = var.app_number >= 101 && var.app_number <= 254 + error_message = "app_number must be between 101 and 254." + } +} + +# ECS Configuration +variable "ecs_task_cpu" { + description = "CPU units for ECS task (256 = 0.25 vCPU, 512 = 0.5 vCPU, 1024 = 1 vCPU)" + type = number + default = 1024 +} + +variable "ecs_task_memory" { + description = "Memory for ECS task in MB" + type = number + default = 2048 +} + +variable "ecs_desired_count" { + description = "Desired number of ECS tasks" + type = number + default = 1 +} + +variable "ecs_min_capacity" { + description = "Minimum number of ECS tasks for auto-scaling" + type = number + default = 1 +} + +variable "ecs_max_capacity" { + description = "Maximum number of ECS tasks for auto-scaling" + type = number + default = 3 +} + +variable "container_port" { + description = "Port exposed by the container" + type = number + default = 80 +} + +# RDS Configuration +variable "db_instance_class" { + description = "RDS instance class" + type = string + default = "db.t4g.micro" +} + +variable "db_engine_version" { + description = "MySQL engine version" + type = string + default = "8.0" +} + +variable "db_allocated_storage" { + description = "Allocated storage in GB" + type = number + default = 20 +} + +variable "db_name" { + description = "Database name" + type = string + default = "drupal" +} + +variable "db_username" { + description = "Database admin username" + type = string + default = "madmin" +} + +variable "db_port" { + description = "Database port" + type = number + default = 3306 +} + +# ElastiCache Configuration +variable "redis_node_type" { + description = "ElastiCache node type" + type = string + default = "cache.t4g.micro" +} + +variable "redis_engine_version" { + description = "Redis engine version" + type = string + default = "7.0" +} + +variable "redis_port" { + description = "Redis port" + type = number + default = 6379 +} + +# GitHub Configuration +variable "github_owner" { + description = "GitHub organization or user" + type = string + default = "UCEAP" +} + +variable "github_repo" { + description = "GitHub repository name" + type = string + default = "drupal-example" +} diff --git a/terraform/vpc.tf b/terraform/vpc.tf new file mode 100644 index 0000000..77f12d4 --- /dev/null +++ b/terraform/vpc.tf @@ -0,0 +1,180 @@ +# CIDR Locals for consistent, readable subnet allocation +locals { + vpc_cidr = "10.${var.app_number}.0.0/16" + + # Public subnets (ALB) + public_cidr_az1 = "10.${var.app_number}.0.0/24" + public_cidr_az2 = "10.${var.app_number}.1.0/24" + + # Private subnets (ECS Tasks) + private_cidr_az1 = "10.${var.app_number}.10.0/24" + private_cidr_az2 = "10.${var.app_number}.11.0/24" + + # Database subnets (RDS/Redis) + database_cidr_az1 = "10.${var.app_number}.20.0/24" + database_cidr_az2 = "10.${var.app_number}.21.0/24" +} + +# VPC +resource "aws_vpc" "main" { + cidr_block = local.vpc_cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = { + Name = "${var.name_prefix}-vpc" + } +} + +# Internet Gateway +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "${var.name_prefix}-igw" + } +} + +# Public Subnets (for ALB) +resource "aws_subnet" "public" { + count = length(var.availability_zones) + vpc_id = aws_vpc.main.id + cidr_block = count.index == 0 ? local.public_cidr_az1 : local.public_cidr_az2 + availability_zone = var.availability_zones[count.index] + map_public_ip_on_launch = true + + tags = { + Name = "${var.name_prefix}-public-${var.availability_zones[count.index]}" + Type = "public" + } +} + +# Private Subnets (for ECS Tasks) +resource "aws_subnet" "private" { + count = length(var.availability_zones) + vpc_id = aws_vpc.main.id + cidr_block = count.index == 0 ? local.private_cidr_az1 : local.private_cidr_az2 + availability_zone = var.availability_zones[count.index] + + tags = { + Name = "${var.name_prefix}-private-${var.availability_zones[count.index]}" + Type = "private" + } +} + +# Database Subnets (for RDS and ElastiCache) +resource "aws_subnet" "database" { + count = length(var.availability_zones) + vpc_id = aws_vpc.main.id + cidr_block = count.index == 0 ? local.database_cidr_az1 : local.database_cidr_az2 + availability_zone = var.availability_zones[count.index] + + tags = { + Name = "${var.name_prefix}-database-${var.availability_zones[count.index]}" + Type = "database" + } +} + +# Elastic IP for NAT Gateway +resource "aws_eip" "nat" { + count = length(var.availability_zones) + domain = "vpc" + + tags = { + Name = "${var.name_prefix}-nat-eip-${var.availability_zones[count.index]}" + } + + depends_on = [aws_internet_gateway.main] +} + +# NAT Gateway (in public subnets) +resource "aws_nat_gateway" "main" { + count = length(var.availability_zones) + allocation_id = aws_eip.nat[count.index].id + subnet_id = aws_subnet.public[count.index].id + + tags = { + Name = "${var.name_prefix}-nat-${var.availability_zones[count.index]}" + } + + depends_on = [aws_internet_gateway.main] +} + +# Public Route Table +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = { + Name = "${var.name_prefix}-public-rt" + } +} + +# Public Route Table Association +resource "aws_route_table_association" "public" { + count = length(var.availability_zones) + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +# Private Route Tables (one per AZ for NAT Gateway) +resource "aws_route_table" "private" { + count = length(var.availability_zones) + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.main[count.index].id + } + + tags = { + Name = "${var.name_prefix}-private-rt-${var.availability_zones[count.index]}" + } +} + +# Private Route Table Association +resource "aws_route_table_association" "private" { + count = length(var.availability_zones) + subnet_id = aws_subnet.private[count.index].id + route_table_id = aws_route_table.private[count.index].id +} + +# Database Route Table (no internet access) +resource "aws_route_table" "database" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "${var.name_prefix}-database-rt" + } +} + +# Database Route Table Association +resource "aws_route_table_association" "database" { + count = length(var.availability_zones) + subnet_id = aws_subnet.database[count.index].id + route_table_id = aws_route_table.database.id +} + +# DB Subnet Group for RDS +resource "aws_db_subnet_group" "main" { + name = "${var.name_prefix}-db-subnet-group" + subnet_ids = aws_subnet.database[*].id + + tags = { + Name = "${var.name_prefix}-db-subnet-group" + } +} + +# ElastiCache Subnet Group +resource "aws_elasticache_subnet_group" "main" { + name = "${var.name_prefix}-redis-subnet-group" + subnet_ids = aws_subnet.database[*].id + + tags = { + Name = "${var.name_prefix}-redis-subnet-group" + } +} diff --git a/terraform/waf.tf b/terraform/waf.tf new file mode 100644 index 0000000..6b2e323 --- /dev/null +++ b/terraform/waf.tf @@ -0,0 +1,157 @@ +# AWS WAF Web ACL for ALB protection +resource "aws_wafv2_web_acl" "alb" { + name = "${var.name_prefix}-alb-waf" + description = "WAF rules for ALB protecting Drupal application" + scope = "REGIONAL" + + default_action { + allow {} + } + + # Rule 1: AWS Managed Rules - Core Rule Set (OWASP Top 10) + rule { + name = "AWSManagedRulesCommonRuleSet" + priority = 0 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesCommonRuleSet" + vendor_name = "AWS" + + # Exclude rules that may conflict with Drupal + rule_action_override { + name = "SizeRestrictions_BODY" + action_to_use { + count {} + } + } + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "AWSManagedRulesCommonRuleSetMetric" + sampled_requests_enabled = true + } + } + + # Rule 2: AWS Managed Rules - Known Bad Inputs + rule { + name = "AWSManagedRulesKnownBadInputsRuleSet" + priority = 1 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesKnownBadInputsRuleSet" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "AWSManagedRulesKnownBadInputsRuleSetMetric" + sampled_requests_enabled = true + } + } + + # Rule 3: AWS Managed Rules - SQL Injection Protection + rule { + name = "AWSManagedRulesSQLiRuleSet" + priority = 2 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesSQLiRuleSet" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "AWSManagedRulesSQLiRuleSetMetric" + sampled_requests_enabled = true + } + } + + # Rule 4: Rate Limiting (prevent brute force attacks) + rule { + name = "RateLimitRule" + priority = 3 + + action { + block {} + } + + statement { + rate_based_statement { + limit = 2000 + aggregate_key_type = "IP" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "RateLimitRuleMetric" + sampled_requests_enabled = true + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name_prefix}-alb-waf" + sampled_requests_enabled = true + } + + tags = { + Name = "${var.name_prefix}-alb-waf" + } +} + +# Associate WAF with ALB +resource "aws_wafv2_web_acl_association" "alb" { + resource_arn = aws_lb.main.arn + web_acl_arn = aws_wafv2_web_acl.alb.arn +} + +# Configure WAF Logging to CloudWatch +# Logs all traffic evaluated by the WAF (including blocked and allowed requests) +resource "aws_wafv2_web_acl_logging_configuration" "alb" { + resource_arn = aws_wafv2_web_acl.alb.arn + log_destination_configs = [aws_cloudwatch_log_group.waf.arn] + + logging_filter { + default_behavior = "KEEP" + + filter { + behavior = "DROP" + condition { + action_condition { + action = "BLOCK" + } + } + requirement = "MEETS_ANY" + } + } + + redacted_fields { + single_header { + name = "authorization" + } + } + + depends_on = [ + aws_cloudwatch_log_group.waf + ] +}