diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh index ada0c97..98cceeb 100755 --- a/.devcontainer/onCreate.sh +++ b/.devcontainer/onCreate.sh @@ -1,26 +1,21 @@ #!/bin/bash set -eo pipefail -# Download and extract files -if [ -z "$TERMINUS_TOKEN" ]; then - # Fallback to DEVOPS_TERMINUS_TOKEN if personal token is not set (intended for Codespaces). - if [ -z "$DEVOPS_TERMINUS_TOKEN" ]; then - echo "Please set the TERMINUS_TOKEN environment variable." - exit 1 - fi - export TERMINUS_TOKEN=$DEVOPS_TERMINUS_TOKEN -fi -terminus auth:login --machine-token=$TERMINUS_TOKEN -export TERMINUS_ENV="dev" -terminus backup:get --element=files --to=files.tar.gz -tar zx --no-same-permissions --strip-components 1 -C web/sites/default/files -f files.tar.gz -rm files.tar.gz +# Install Terraform +# TODO move this into base image -# no-same-permissions doesn't seem to work so we fix it here -sudo find web/sites/default/files -type d -exec chmod g+ws {} + -sudo find web/sites/default/files -type f -exec chmod g+w {} + +sudo apt-get install -y gnupg +wget -O- https://apt.releases.hashicorp.com/gpg | \ + gpg --dearmor | \ + sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null +gpg --no-default-keyring \ + --keyring /usr/share/keyrings/hashicorp-archive-keyring.gpg \ + --fingerprint +echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \ + sudo tee /etc/apt/sources.list.d/hashicorp.list > /dev/null +sudo apt update +sudo apt-get install -y terraform -# Set up infrastructure tooling +# Install AWS CLI # TODO move this into base image -build/install-terraform.sh -build/install-azure-cli.sh \ No newline at end of file +sudo apt-get install -y awscli diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh deleted file mode 100755 index 79d8f95..0000000 --- a/.devcontainer/postCreate.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -# set ports to be publicly accessible -if [[ -n "$CODESPACE_NAME" ]]; then - gh codespace ports visibility 8080:public -c $CODESPACE_NAME -fi \ No newline at end of file diff --git a/.github/workflows/build_deploy_and_test.yml b/.github/workflows/build_deploy_and_test.yml index ddbcdf0..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: - 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 + 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: + 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 @@ -252,10 +208,16 @@ jobs: fi echo TERMINUS_ENV=$TERMINUS_ENV >> $GITHUB_ENV echo terminus_env=$TERMINUS_ENV >> $GITHUB_OUTPUT + - name: Load secrets + uses: 1password/load-secrets-action@v2 + with: + export-env: true + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + TERMINUS_TOKEN: op://secrets-example/pantheon-terminus/credential - name: Create Pantheon environment env: TERMINUS_SITE: ${{ vars.TERMINUS_SITE }} - TERMINUS_TOKEN: ${{ secrets.TERMINUS_TOKEN }} run: | terminus -n auth:login --machine-token="$TERMINUS_TOKEN" set +e @@ -278,12 +240,18 @@ jobs: name: deployment-build - name: Fix file modes run: find vendor/bin -type f | xargs chmod +x + - name: Load secrets + uses: 1password/load-secrets-action@v2 + with: + export-env: true + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + TERMINUS_TOKEN: op://secrets-example/pantheon-terminus/credential + SSH_PRIVATE_KEY: op://secrets-example/pantheon-ssh/private key - name: deploy to Pantheon env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} TERMINUS_SITE: ${{ vars.TERMINUS_SITE }} - TERMINUS_ENV: ${{needs.prepare_pantheon.outputs.terminus_env}} - TERMINUS_TOKEN: ${{ secrets.TERMINUS_TOKEN }} + TERMINUS_ENV: ${{ needs.prepare_pantheon.outputs.terminus_env }} run: | echo "$SSH_PRIVATE_KEY" > ../private.key chmod 600 ../private.key @@ -300,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/.github/workflows/cleanup_multidevs.yml b/.github/workflows/cleanup_multidevs.yml new file mode 100644 index 0000000..800b8f4 --- /dev/null +++ b/.github/workflows/cleanup_multidevs.yml @@ -0,0 +1,28 @@ +name: Cleanup multidevs +on: + pull_request: + types: [closed] +defaults: + run: + shell: bash + +jobs: + cleanup_multidevs: + name: Cleanup multidevs + runs-on: ubuntu-latest + container: + image: ghcr.io/uceap/devcontainer-drupal:main + steps: + - name: Load secrets + uses: 1password/load-secrets-action@v2 + with: + export-env: true + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + TERMINUS_TOKEN: op://secrets-example/pantheon-terminus/credential + - name: Prune stale multidev environments + env: + TERMINUS_SITE: ${{ vars.TERMINUS_SITE }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: uceap cleanup-multidevs diff --git a/.github/workflows/deploy_to_live.yml b/.github/workflows/deploy_to_live.yml index 5cc0d71..ad56e67 100644 --- a/.github/workflows/deploy_to_live.yml +++ b/.github/workflows/deploy_to_live.yml @@ -6,10 +6,16 @@ jobs: container: image: ghcr.io/uceap/devcontainer-drupal:main env: - TERMINUS_TOKEN: ${{ secrets.TERMINUS_TOKEN }} TERMINUS_SITE: ${{ vars.TERMINUS_SITE }} - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} steps: + - name: Load secrets + uses: 1password/load-secrets-action@v2 + with: + export-env: true + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + TERMINUS_TOKEN: op://secrets-example/pantheon-terminus/credential + SSH_PRIVATE_KEY: op://secrets-example/pantheon-ssh/private key - name: Deploy to LIVE run: | echo "$SSH_PRIVATE_KEY" > ../private.key diff --git a/.github/workflows/deploy_to_test.yml b/.github/workflows/deploy_to_test.yml index 02a4029..7324122 100644 --- a/.github/workflows/deploy_to_test.yml +++ b/.github/workflows/deploy_to_test.yml @@ -16,11 +16,16 @@ jobs: container: image: ghcr.io/uceap/devcontainer-drupal:main env: - TERMINUS_TOKEN: ${{ secrets.TERMINUS_TOKEN }} TERMINUS_SITE: ${{ vars.TERMINUS_SITE }} - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - DRUSH_TASK: ${{ vars.DRUSH_TASK }} steps: + - name: Load secrets + uses: 1password/load-secrets-action@v2 + with: + export-env: true + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + TERMINUS_TOKEN: op://secrets-example/pantheon-terminus/credential + SSH_PRIVATE_KEY: op://secrets-example/pantheon-ssh/private key - name: Sync content and deploy to TEST if: ${{ github.event.inputs.deployment_type == 'normal' }} run: | 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/build/db-backup.sh b/build/db-backup.sh deleted file mode 100755 index 874c18e..0000000 --- a/build/db-backup.sh +++ /dev/null @@ -1,90 +0,0 @@ -#!/bin/bash - -# Set the things -HOST=${DB_HOST:-localhost} -USER=${DB_USER:-${MYSQL_USER:-root}} -PASSWORD=${DB_PASSWORD:-${MYSQL_PASSWORD:-}} -DATABASE=${DB_NAME:-${MYSQL_DATABASE:-database}} -PORT=${DB_PORT:-3306} -MOUNT=${LANDO_MOUNT:-default_value} -WEBROOT=${LANDO_WEBROOT:-default_value} -TERMINUSENV=${TERMINUS_ENV:-dev} -BACKUPPATH=${BACKUP_PATH:-~/Sites} -# PARSE THE ARGZZ -# TODO: compress the mostly duplicate code below? -while (( "$#" )); do - case "$1" in - -h|--host|--host=*) - if [ "${1##--host=}" != "$1" ]; then - HOST="${1##--host=}" - shift - else - HOST=$2 - shift 2 - fi - ;; - -u|--user|--user=*) - if [ "${1##--user=}" != "$1" ]; then - USER="${1##--user=}" - shift - else - USER=$2 - shift 2 - fi - ;; - -p|--password|--password=*) - if [ "${1##--password=}" != "$1" ]; then - PASSWORD="${1##--password=}" - shift - else - PASSWORD=$2 - shift 2 - fi - ;; - -d|--database|--database=*) - if [ "${1##--database=}" != "$1" ]; then - DATABASE="${1##--database=}" - shift - else - DATABASE=$2 - shift 2 - fi - ;; - -P|--port|--port=*) - if [ "${1##--port=}" != "$1" ]; then - PORT="${1##--port=}" - shift - else - PORT=$2 - shift 2 - fi - ;; - --) - shift - break - ;; - -*|--*=) - echo "Error: Unsupported flag $1" >&2 - exit 1 - ;; - *) - FILE="$(pwd)/$1" - shift - ;; - esac -done -# Test if FILE exists if not touch it to gzip to it later. -if [ -z "$FILE" ]; then - FILENAME="$DATABASE.$(date +%F.%H%M%S).sql.gz" - eval "touch -c $BACKUPPATH/$FILENAME" - FILE="$BACKUPPATH/$FILENAME" -fi -echo "$FILE" - -# reset db back to default repo state -echo "Exporting and gzipping $DATABASE @ $HOST:$PORT as $USER to file:$FILE" -if [ ! -z "$PASSWORD" ]; then - eval "mysqldump -h $HOST -P $PORT --protocol=tcp -u $USER -p$PASSWORD $DATABASE | gzip > $FILE" -else - eval "mysqldump -h $HOST -P $PORT --protocol=tcp -u $USER $DATABASE | gzip > $FILE" -fi diff --git a/build/db-rebuild.sh b/build/db-rebuild.sh deleted file mode 100755 index 2847cf5..0000000 --- a/build/db-rebuild.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/bin/bash - -# Set the things -HOST=${DB_HOST:-${MYSQL_HOST:-127.0.0.1}} -USER=${DB_USER:-${MYSQL_USER:-root}} -PASSWORD=${DB_PASSWORD:-${MYSQL_PASSWORD:-}} -DATABASE=${DB_NAME:-${MYSQL_DATABASE:-database}} -PORT=${DB_PORT:-${MYSQL_TCP_PORT:-3306}} -WEBROOT=${LANDO_WEBROOT:-$(dirname $(dirname $(realpath $0)))} -TERMINUSENV=${TERMINUS_ENV:-dev} - -# PARSE THE ARGZZ -# TODO: compress the mostly duplicate code below? -while (( "$#" )); do - case "$1" in - -h|--host|--host=*) - if [ "${1##--host=}" != "$1" ]; then - HOST="${1##--host=}" - shift - else - HOST=$2 - shift 2 - fi - ;; - -u|--user|--user=*) - if [ "${1##--user=}" != "$1" ]; then - USER="${1##--user=}" - shift - else - USER=$2 - shift 2 - fi - ;; - -p|--password|--password=*) - if [ "${1##--password=}" != "$1" ]; then - PASSWORD="${1##--password=}" - shift - else - PASSWORD=$2 - shift 2 - fi - ;; - -d|--database|--database=*) - if [ "${1##--database=}" != "$1" ]; then - DATABASE="${1##--database=}" - shift - else - DATABASE=$2 - shift 2 - fi - ;; - -P|--port|--port=*) - if [ "${1##--port=}" != "$1" ]; then - PORT="${1##--port=}" - shift - else - PORT=$2 - shift 2 - fi - ;; - --) - shift - break - ;; - -*|--*=) - echo "Error: Unsupported flag $1" >&2 - exit 1 - ;; - *) - FILE="$(pwd)/$1" - shift - ;; - esac -done - echo "$FILE" -# Test if FILE is set if not download pantheon's production db to temp file. -if [ -z "$FILE" ]; then - # generate temp file - FILE=$(mktemp) || exit 1 - # grab latest production db backup. - curl `terminus backup:get --element=db myeap2.$TERMINUSENV` --output $FILE - function cleanup { - rm "$FILE" - } - trap cleanup EXIT -fi - -# Set positional arguments in their proper place -eval set -- "$FILE" -CMD="$FILE" -PV="" - -# Validate we have a file -if [ ! -f "$FILE" ]; then - echo "File $FILE not found!" - exit 1; -fi - -# reset db back to default repo state -echo "Dropping and re-creating $DATABASE @ $HOST:$PORT as $USER..." -if [ ! -z "$PASSWORD" ]; then - eval "mysqladmin -h$HOST -u$USER -p$PASSWORD drop $DATABASE -f" - eval "mysqladmin -h$HOST -u$USER -p$PASSWORD create $DATABASE -f" -else - eval "mysqladmin -h$HOST -u$USER drop $DATABASE -f" - eval "mysqladmin -h$HOST -u$USER create $DATABASE -f" -fi - -# Inform the user of things -echo "Preparing to import $FILE into $DATABASE on $HOST:$PORT as $USER..." - -# Check to see if we have any unzipping options or GUI needs -if command -v gunzip >/dev/null 2>&1 && gunzip -t $FILE >/dev/null 2>&1; then - echo "Gunzipped file detected!" - if command -v pv >/dev/null 2>&1; then - CMD="pv $CMD" - else - CMD="cat $CMD" - fi - CMD="$CMD | gunzip" -elif command -v unzip >/dev/null 2>&1 && unzip -t $FILE >/dev/null 2>&1; then - echo "Zipped file detected!" - CMD="unzip -p $CMD" - if command -v pv >/dev/null 2>&1; then - CMD="$CMD | pv" - fi -else - if command -v pv >/dev/null 2>&1; then - CMD="pv $CMD" - else - CMD="cat $CMD" - fi -fi - -# Put the pieces together -CMD="$CMD | mysql -h $HOST -P $PORT --protocol tcp -u $USER" -if [ ! -z "$PASSWORD" ]; then - CMD="$CMD -p$PASSWORD $DATABASE" -else - CMD="$CMD $DATABASE" -fi - -# Import -echo "Importing $FILE..." -eval "$CMD" -echo "Import completed with status code $?" - -# update db with latest code/config changes -echo "Running drush deploy to update DB with latest configs." -eval "cd $WEBROOT && drush deploy" diff --git a/build/install-azure-cli.sh b/build/install-azure-cli.sh deleted file mode 100755 index ace7cc4..0000000 --- a/build/install-azure-cli.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -sudo apt-get update -sudo apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release -sudo mkdir -p /etc/apt/keyrings -curl -sLS https://packages.microsoft.com/keys/microsoft.asc | \ - gpg --dearmor | sudo tee /etc/apt/keyrings/microsoft.gpg > /dev/null -sudo chmod go+r /etc/apt/keyrings/microsoft.gpg -AZ_DIST=$(lsb_release -cs) -echo "Types: deb -URIs: https://packages.microsoft.com/repos/azure-cli/ -Suites: ${AZ_DIST} -Components: main -Architectures: $(dpkg --print-architecture) -Signed-by: /etc/apt/keyrings/microsoft.gpg" | sudo tee /etc/apt/sources.list.d/azure-cli.sources > /dev/null -sudo apt-get update -sudo apt-get install -y azure-cli diff --git a/build/install-terraform.sh b/build/install-terraform.sh deleted file mode 100755 index c0473e5..0000000 --- a/build/install-terraform.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -sudo apt-get update -sudo apt-get install -y gnupg software-properties-common -wget -O- https://apt.releases.hashicorp.com/gpg | \ - gpg --dearmor | \ - sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null -gpg --no-default-keyring \ - --keyring /usr/share/keyrings/hashicorp-archive-keyring.gpg \ - --fingerprint -echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \ - sudo tee /etc/apt/sources.list.d/hashicorp.list > /dev/null -sudo apt update -sudo apt-get install -y terraform diff --git a/build/templater.sh b/build/templater.sh deleted file mode 100755 index ec6e07f..0000000 --- a/build/templater.sh +++ /dev/null @@ -1,172 +0,0 @@ -#!/bin/bash -# -# Very simple templating system that replaces {{VAR}} by the value of $VAR. -# Supports default values by writting {{VAR=value}} in the template. -# -# Copyright (c) 2017 Sébastien Lavoie -# Copyright (c) 2017 Johan Haleby -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -# See: https://github.com/johanhaleby/bash-templater -# Version: https://github.com/johanhaleby/bash-templater/commit/5ac655d554238ac70b08ee4361d699ea9954c941 - -# Replaces all {{VAR}} by the $VAR value in a template file and outputs it - -readonly PROGNAME=$(basename $0) - -config_file="" -print_only="false" -silent="false" - -usage="${PROGNAME} [-h] [-d] [-f] [-s] -- - -where: - -h, --help - Show this help text - -p, --print - Don't do anything, just print the result of the variable expansion(s) - -f, --file - Specify a file to read variables from - -s, --silent - Don't print warning messages (for example if no variables are found) - -examples: - VAR1=Something VAR2=1.2.3 ${PROGNAME} test.txt - ${PROGNAME} test.txt -f my-variables.txt - ${PROGNAME} test.txt -f my-variables.txt > new-test.txt" - -if [ $# -eq 0 ]; then - echo "$usage" - exit 1 -fi - -if [[ ! -f "${1}" ]]; then - echo "You need to specify a template file" >&2 - echo "$usage" - exit 1 -fi - -template="${1}" - -if [ "$#" -ne 0 ]; then - while [ "$#" -gt 0 ] - do - case "$1" in - -h|--help) - echo "$usage" - exit 0 - ;; - -p|--print) - print_only="true" - ;; - -f|--file) - config_file="$2" - ;; - -s|--silent) - silent="true" - ;; - --) - break - ;; - -*) - echo "Invalid option '$1'. Use --help to see the valid options" >&2 - exit 1 - ;; - # an option argument, continue - *) ;; - esac - shift - done -fi - -vars=$(grep -oE '\{\{[A-Za-z0-9_]+\}\}' "${template}" | sort | uniq | sed -e 's/^{{//' -e 's/}}$//') - -if [[ -z "$vars" ]]; then - if [ "$silent" == "false" ]; then - echo "Warning: No variable was found in ${template}, syntax is {{VAR}}" >&2 - fi -fi - -# Load variables from file if needed -if [ "${config_file}" != "" ]; then - if [[ ! -f "${config_file}" ]]; then - echo "The file ${config_file} does not exists" >&2 - echo "$usage" - exit 1 - fi - - # Create temp file where & and "space" is escaped - tmpfile=`mktemp` - sed -e "s;\&;\\\&;g" -e "s;\ ;\\\ ;g" "${config_file}" > $tmpfile - source $tmpfile -fi - -var_value() { - eval echo \$$1 -} - -replaces="" - -# Reads default values defined as {{VAR=value}} and delete those lines -# There are evaluated, so you can do {{PATH=$HOME}} or {{PATH=`pwd`}} -# You can even reference variables defined in the template before -defaults=$(grep -oE '^\{\{[A-Za-z0-9_]+=.+\}\}' "${template}" | sed -e 's/^{{//' -e 's/}}$//') - -for default in $defaults; do - var=$(echo "$default" | grep -oE "^[A-Za-z0-9_]+") - current=`var_value $var` - - # Replace only if var is not set - if [[ -z "$current" ]]; then - eval $default - fi - - # remove define line - replaces="-e '/^{{$var=/d' $replaces" - vars="$vars -$current" -done - -vars=$(echo $vars | sort | uniq) - -if [[ "$print_only" == "true" ]]; then - for var in $vars; do - value=`var_value $var` - echo "$var = $value" - done - exit 0 -fi - -# Replace all {{VAR}} by $VAR value -for var in $vars; do - value=`var_value $var` - if [[ -z "$value" ]]; then - if [ $silent == "false" ]; then - echo "Warning: $var is not defined and no default is set, replacing by empty" >&2 - fi - fi - - # Escape slashes - value=$(echo "$value" | sed 's/\//\\\//g'); - replaces="-e 's/{{$var}}/${value}/g' $replaces" -done - -escaped_template_path=$(echo $template | sed 's/ /\\ /g') -eval sed $replaces "$escaped_template_path" diff --git a/composer.json b/composer.json index 977912c..a682f87 100644 --- a/composer.json +++ b/composer.json @@ -45,14 +45,14 @@ "echo 'Compiling theme...'" ], "dev-initialize-local": [ - "build/templater.sh web/sites/template.settings.local.php > web/sites/default/settings.local.php", + "templater.sh web/sites/template.settings.local.php > web/sites/default/settings.local.php", "cp web/sites/template.services.local.yml web/sites/default/default.services.local.yml", "echo >> web/sites/default/settings.local.php", "echo \"\\$settings['hash_salt'] = '`dd if=/dev/random bs=1k count=1 2> /dev/null | shasum | cut -c1-40`';\" >> web/sites/default/settings.local.php", "mkdir -p web/sites/default/files" ], "initialize-container": [ - "build/templater.sh web/sites/template.settings.container.php > web/sites/default/settings.container.php", + "templater.sh web/sites/template.settings.container.php > web/sites/default/settings.container.php", "mkdir -p web/sites/default/files" ], "lint": "find web/modules/custom -name '*.php' | xargs php -l", diff --git a/composer.lock b/composer.lock index ba69f78..5071148 100644 --- a/composer.lock +++ b/composer.lock @@ -64,16 +64,16 @@ }, { "name": "chi-teck/drupal-code-generator", - "version": "4.1.0", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/Chi-teck/drupal-code-generator.git", - "reference": "9a5501beb1a7aa2400afa5e5679bf21c526c497c" + "reference": "984dd69522b5839976df51470a00a51616a21f42" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Chi-teck/drupal-code-generator/zipball/9a5501beb1a7aa2400afa5e5679bf21c526c497c", - "reference": "9a5501beb1a7aa2400afa5e5679bf21c526c497c", + "url": "https://api.github.com/repos/Chi-teck/drupal-code-generator/zipball/984dd69522b5839976df51470a00a51616a21f42", + "reference": "984dd69522b5839976df51470a00a51616a21f42", "shasum": "" }, "require": { @@ -92,7 +92,7 @@ "squizlabs/php_codesniffer": "<3.6" }, "require-dev": { - "chi-teck/drupal-coder-extension": "^2.0.0-beta3", + "chi-teck/drupal-coder-extension": "^2.0.0-rc2", "drupal/coder": "8.3.24", "drupal/core": "11.x-dev", "ext-simplexml": "*", @@ -119,9 +119,9 @@ "description": "Drupal code generator", "support": { "issues": "https://github.com/Chi-teck/drupal-code-generator/issues", - "source": "https://github.com/Chi-teck/drupal-code-generator/tree/4.1.0" + "source": "https://github.com/Chi-teck/drupal-code-generator/tree/4.2.0" }, - "time": "2024-10-30T18:25:43+00:00" + "time": "2025-06-01T13:48:30+00:00" }, { "name": "composer/installers", @@ -408,23 +408,23 @@ }, { "name": "consolidation/config", - "version": "3.1.0", + "version": "3.1.1", "source": { "type": "git", "url": "https://github.com/consolidation/config.git", - "reference": "0615499781449ab773ffc609b97b934b3357b3f9" + "reference": "54bb59d156e01698cd52d4dbbf6df98924f9ff7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/consolidation/config/zipball/0615499781449ab773ffc609b97b934b3357b3f9", - "reference": "0615499781449ab773ffc609b97b934b3357b3f9", + "url": "https://api.github.com/repos/consolidation/config/zipball/54bb59d156e01698cd52d4dbbf6df98924f9ff7e", + "reference": "54bb59d156e01698cd52d4dbbf6df98924f9ff7e", "shasum": "" }, "require": { "dflydev/dot-access-data": "^3", "grasmash/expander": "^3", "php": ">=8.2.0", - "symfony/event-dispatcher": "^7" + "symfony/event-dispatcher": "^6 || ^7" }, "require-dev": { "ext-json": "*", @@ -441,7 +441,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.x-dev" + "dev-main": "3.x-dev" } }, "autoload": { @@ -462,9 +462,9 @@ "description": "Provide configuration services for a commandline tool.", "support": { "issues": "https://github.com/consolidation/config/issues", - "source": "https://github.com/consolidation/config/tree/3.1.0" + "source": "https://github.com/consolidation/config/tree/3.1.1" }, - "time": "2024-11-28T14:37:27+00:00" + "time": "2025-07-07T13:37:38+00:00" }, { "name": "consolidation/filter-via-dot-access-data", @@ -1498,16 +1498,16 @@ }, { "name": "drush/drush", - "version": "13.3.3", + "version": "13.6.0", "source": { "type": "git", "url": "https://github.com/drush-ops/drush.git", - "reference": "d124723dacb4208ccb875b7114722e420fccf06d" + "reference": "570a05dce7aea9770f17306808804290764127ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/drush-ops/drush/zipball/d124723dacb4208ccb875b7114722e420fccf06d", - "reference": "d124723dacb4208ccb875b7114722e420fccf06d", + "url": "https://api.github.com/repos/drush-ops/drush/zipball/570a05dce7aea9770f17306808804290764127ad", + "reference": "570a05dce7aea9770f17306808804290764127ad", "shasum": "" }, "require": { @@ -1525,7 +1525,7 @@ "ext-dom": "*", "grasmash/yaml-cli": "^3.1", "guzzlehttp/guzzle": "^7.0", - "laravel/prompts": "^0.1.21", + "laravel/prompts": "^0.3.5", "league/container": "^4.2", "php": ">=8.2", "psy/psysh": "~0.12", @@ -1543,7 +1543,7 @@ "require-dev": { "composer/installers": "^2", "cweagans/composer-patches": "~1.7.3", - "drupal/core-recommended": "^10.2.5 || 11.0.x-dev", + "drupal/core-recommended": "^10.2.5 || 11.x-dev", "drupal/semver_example": "2.3.0", "jetbrains/phpstorm-attributes": "^1.0", "mglaman/phpstan-drupal": "^1.2", @@ -1634,7 +1634,7 @@ "issues": "https://github.com/drush-ops/drush/issues", "security": "https://github.com/drush-ops/drush/security/advisories", "slack": "https://drupal.slack.com/messages/C62H9CWQM", - "source": "https://github.com/drush-ops/drush/tree/13.3.3" + "source": "https://github.com/drush-ops/drush/tree/13.6.0" }, "funding": [ { @@ -1642,7 +1642,7 @@ "type": "github" } ], - "time": "2024-11-10T20:02:03+00:00" + "time": "2025-04-22T12:14:13+00:00" }, { "name": "egulias/email-validator", @@ -1821,16 +1821,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.9.2", + "version": "7.9.3", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "d281ed313b989f213357e3be1a179f02196ac99b" + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b", - "reference": "d281ed313b989f213357e3be1a179f02196ac99b", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", "shasum": "" }, "require": { @@ -1927,7 +1927,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.9.2" + "source": "https://github.com/guzzle/guzzle/tree/7.9.3" }, "funding": [ { @@ -1943,7 +1943,7 @@ "type": "tidelift" } ], - "time": "2024-07-24T11:22:20+00:00" + "time": "2025-03-27T13:37:11+00:00" }, { "name": "guzzlehttp/promises", @@ -2030,16 +2030,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.7.0", + "version": "2.7.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201" + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/a70f5c95fb43bc83f07c9c948baa0dc1829bf201", - "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", "shasum": "" }, "require": { @@ -2126,7 +2126,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.7.0" + "source": "https://github.com/guzzle/psr7/tree/2.7.1" }, "funding": [ { @@ -2142,221 +2142,25 @@ "type": "tidelift" } ], - "time": "2024-07-18T11:15:46+00:00" - }, - { - "name": "illuminate/collections", - "version": "v11.43.1", - "source": { - "type": "git", - "url": "https://github.com/illuminate/collections.git", - "reference": "8a650cf80dce7f37f20b8bf33e11922b291bf0e2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/illuminate/collections/zipball/8a650cf80dce7f37f20b8bf33e11922b291bf0e2", - "reference": "8a650cf80dce7f37f20b8bf33e11922b291bf0e2", - "shasum": "" - }, - "require": { - "illuminate/conditionable": "^11.0", - "illuminate/contracts": "^11.0", - "illuminate/macroable": "^11.0", - "php": "^8.2" - }, - "suggest": { - "symfony/var-dumper": "Required to use the dump method (^7.0)." - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "11.x-dev" - } - }, - "autoload": { - "files": [ - "functions.php", - "helpers.php" - ], - "psr-4": { - "Illuminate\\Support\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "The Illuminate Collections package.", - "homepage": "https://laravel.com", - "support": { - "issues": "https://github.com/laravel/framework/issues", - "source": "https://github.com/laravel/framework" - }, - "time": "2025-02-19T15:59:05+00:00" - }, - { - "name": "illuminate/conditionable", - "version": "v11.43.1", - "source": { - "type": "git", - "url": "https://github.com/illuminate/conditionable.git", - "reference": "911df1bda950a3b799cf80671764e34eede131c6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/illuminate/conditionable/zipball/911df1bda950a3b799cf80671764e34eede131c6", - "reference": "911df1bda950a3b799cf80671764e34eede131c6", - "shasum": "" - }, - "require": { - "php": "^8.0.2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "11.x-dev" - } - }, - "autoload": { - "psr-4": { - "Illuminate\\Support\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "The Illuminate Conditionable package.", - "homepage": "https://laravel.com", - "support": { - "issues": "https://github.com/laravel/framework/issues", - "source": "https://github.com/laravel/framework" - }, - "time": "2024-11-21T16:28:56+00:00" - }, - { - "name": "illuminate/contracts", - "version": "v11.43.1", - "source": { - "type": "git", - "url": "https://github.com/illuminate/contracts.git", - "reference": "b350a3cd8450846325cb49e1cbc1293598b18898" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/illuminate/contracts/zipball/b350a3cd8450846325cb49e1cbc1293598b18898", - "reference": "b350a3cd8450846325cb49e1cbc1293598b18898", - "shasum": "" - }, - "require": { - "php": "^8.2", - "psr/container": "^1.1.1|^2.0.1", - "psr/simple-cache": "^1.0|^2.0|^3.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "11.x-dev" - } - }, - "autoload": { - "psr-4": { - "Illuminate\\Contracts\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "The Illuminate Contracts package.", - "homepage": "https://laravel.com", - "support": { - "issues": "https://github.com/laravel/framework/issues", - "source": "https://github.com/laravel/framework" - }, - "time": "2025-02-10T14:20:57+00:00" - }, - { - "name": "illuminate/macroable", - "version": "v11.43.1", - "source": { - "type": "git", - "url": "https://github.com/illuminate/macroable.git", - "reference": "e1cb9e51b9ed5d3c9bc1ab431d0a52fe42a990ed" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/illuminate/macroable/zipball/e1cb9e51b9ed5d3c9bc1ab431d0a52fe42a990ed", - "reference": "e1cb9e51b9ed5d3c9bc1ab431d0a52fe42a990ed", - "shasum": "" - }, - "require": { - "php": "^8.2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "11.x-dev" - } - }, - "autoload": { - "psr-4": { - "Illuminate\\Support\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "The Illuminate Macroable package.", - "homepage": "https://laravel.com", - "support": { - "issues": "https://github.com/laravel/framework/issues", - "source": "https://github.com/laravel/framework" - }, - "time": "2024-06-28T20:10:30+00:00" + "time": "2025-03-27T12:30:47+00:00" }, { "name": "laravel/prompts", - "version": "v0.1.25", + "version": "v0.3.6", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "7b4029a84c37cb2725fc7f011586e2997040bc95" + "reference": "86a8b692e8661d0fb308cec64f3d176821323077" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/7b4029a84c37cb2725fc7f011586e2997040bc95", - "reference": "7b4029a84c37cb2725fc7f011586e2997040bc95", + "url": "https://api.github.com/repos/laravel/prompts/zipball/86a8b692e8661d0fb308cec64f3d176821323077", + "reference": "86a8b692e8661d0fb308cec64f3d176821323077", "shasum": "" }, "require": { + "composer-runtime-api": "^2.2", "ext-mbstring": "*", - "illuminate/collections": "^10.0|^11.0", "php": "^8.1", "symfony/console": "^6.2|^7.0" }, @@ -2365,8 +2169,9 @@ "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0", "mockery/mockery": "^1.5", - "pestphp/pest": "^2.3", + "pestphp/pest": "^2.3|^3.4", "phpstan/phpstan": "^1.11", "phpstan/phpstan-mockery": "^1.1" }, @@ -2376,7 +2181,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "0.1.x-dev" + "dev-main": "0.3.x-dev" } }, "autoload": { @@ -2394,22 +2199,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.1.25" + "source": "https://github.com/laravel/prompts/tree/v0.3.6" }, - "time": "2024-08-12T22:06:33+00:00" + "time": "2025-07-07T14:17:42+00:00" }, { "name": "league/container", - "version": "4.2.4", + "version": "4.2.5", "source": { "type": "git", "url": "https://github.com/thephpleague/container.git", - "reference": "7ea728b013b9a156c409c6f0fc3624071b742dec" + "reference": "d3cebb0ff4685ff61c749e54b27db49319e2ec00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/container/zipball/7ea728b013b9a156c409c6f0fc3624071b742dec", - "reference": "7ea728b013b9a156c409c6f0fc3624071b742dec", + "url": "https://api.github.com/repos/thephpleague/container/zipball/d3cebb0ff4685ff61c749e54b27db49319e2ec00", + "reference": "d3cebb0ff4685ff61c749e54b27db49319e2ec00", "shasum": "" }, "require": { @@ -2470,7 +2275,7 @@ ], "support": { "issues": "https://github.com/thephpleague/container/issues", - "source": "https://github.com/thephpleague/container/tree/4.2.4" + "source": "https://github.com/thephpleague/container/tree/4.2.5" }, "funding": [ { @@ -2478,7 +2283,7 @@ "type": "github" } ], - "time": "2024-11-10T12:42:13+00:00" + "time": "2025-05-20T12:55:37+00:00" }, { "name": "masterminds/html5", @@ -2598,16 +2403,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.4.0", + "version": "v5.5.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494" + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", "shasum": "" }, "require": { @@ -2650,9 +2455,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" }, - "time": "2024-12-30T11:07:19+00:00" + "time": "2025-05-31T08:24:38+00:00" }, { "name": "pantheon-systems/drupal-integrations", @@ -3521,69 +3326,18 @@ }, "time": "2024-09-11T13:17:53+00:00" }, - { - "name": "psr/simple-cache", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/simple-cache.git", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\SimpleCache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interfaces for simple caching", - "keywords": [ - "cache", - "caching", - "psr", - "psr-16", - "simple-cache" - ], - "support": { - "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" - }, - "time": "2021-10-29T13:26:27+00:00" - }, { "name": "psy/psysh", - "version": "v0.12.7", + "version": "v0.12.9", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "d73fa3c74918ef4522bb8a3bf9cab39161c4b57c" + "reference": "1b801844becfe648985372cb4b12ad6840245ace" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/d73fa3c74918ef4522bb8a3bf9cab39161c4b57c", - "reference": "d73fa3c74918ef4522bb8a3bf9cab39161c4b57c", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/1b801844becfe648985372cb4b12ad6840245ace", + "reference": "1b801844becfe648985372cb4b12ad6840245ace", "shasum": "" }, "require": { @@ -3647,9 +3401,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.7" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.9" }, - "time": "2024-12-10T01:58:33+00:00" + "time": "2025-06-23T02:35:06+00:00" }, { "name": "ralouphie/getallheaders", @@ -3836,16 +3590,16 @@ }, { "name": "symfony/console", - "version": "v7.2.1", + "version": "v7.2.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3" + "reference": "a08090dc8d5b6360bf9af0cb0622e8d7279d988f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/fefcc18c0f5d0efe3ab3152f15857298868dc2c3", - "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3", + "url": "https://api.github.com/repos/symfony/console/zipball/a08090dc8d5b6360bf9af0cb0622e8d7279d988f", + "reference": "a08090dc8d5b6360bf9af0cb0622e8d7279d988f", "shasum": "" }, "require": { @@ -3909,7 +3663,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.2.1" + "source": "https://github.com/symfony/console/tree/v7.2.8" }, "funding": [ { @@ -3925,20 +3679,20 @@ "type": "tidelift" } ], - "time": "2024-12-11T03:49:26+00:00" + "time": "2025-06-27T19:53:16+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.2.3", + "version": "v7.2.8", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "1d321c4bc3fe926fd4c38999a4c9af4f5d61ddfc" + "reference": "9b9ddcb60730ee4ae4f1f98c8e3a409d3ae6111c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/1d321c4bc3fe926fd4c38999a4c9af4f5d61ddfc", - "reference": "1d321c4bc3fe926fd4c38999a4c9af4f5d61ddfc", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/9b9ddcb60730ee4ae4f1f98c8e3a409d3ae6111c", + "reference": "9b9ddcb60730ee4ae4f1f98c8e3a409d3ae6111c", "shasum": "" }, "require": { @@ -3946,7 +3700,7 @@ "psr/container": "^1.1|^2.0", "symfony/deprecation-contracts": "^2.5|^3", "symfony/service-contracts": "^3.5", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^6.4.20|^7.2.5" }, "conflict": { "ext-psr": "<1.1|>=2", @@ -3989,7 +3743,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.2.3" + "source": "https://github.com/symfony/dependency-injection/tree/v7.2.8" }, "funding": [ { @@ -4005,7 +3759,7 @@ "type": "tidelift" } ], - "time": "2025-01-17T10:56:55+00:00" + "time": "2025-06-24T04:04:14+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5274,7 +5028,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -5330,7 +5084,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.32.0" }, "funding": [ { @@ -5426,16 +5180,16 @@ }, { "name": "symfony/process", - "version": "v7.2.0", + "version": "v7.2.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e" + "reference": "87b7c93e57df9d8e39a093d32587702380ff045d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", - "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", + "url": "https://api.github.com/repos/symfony/process/zipball/87b7c93e57df9d8e39a093d32587702380ff045d", + "reference": "87b7c93e57df9d8e39a093d32587702380ff045d", "shasum": "" }, "require": { @@ -5467,7 +5221,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.2.0" + "source": "https://github.com/symfony/process/tree/v7.2.5" }, "funding": [ { @@ -5483,7 +5237,7 @@ "type": "tidelift" } ], - "time": "2024-11-06T14:24:19+00:00" + "time": "2025-03-13T12:21:46+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -5832,16 +5586,16 @@ }, { "name": "symfony/string", - "version": "v7.2.0", + "version": "v7.2.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" + "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", + "url": "https://api.github.com/repos/symfony/string/zipball/a214fe7d62bd4df2a76447c67c6b26e1d5e74931", + "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931", "shasum": "" }, "require": { @@ -5899,7 +5653,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.2.0" + "source": "https://github.com/symfony/string/tree/v7.2.6" }, "funding": [ { @@ -5915,7 +5669,7 @@ "type": "tidelift" } ], - "time": "2024-11-13T13:31:26+00:00" + "time": "2025-04-20T20:18:16+00:00" }, { "name": "symfony/translation-contracts", @@ -6094,16 +5848,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.2.3", + "version": "v7.2.8", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "82b478c69745d8878eb60f9a049a4d584996f73a" + "reference": "eb2a9537910b3a5040efc0a7860f8128d4b259aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/82b478c69745d8878eb60f9a049a4d584996f73a", - "reference": "82b478c69745d8878eb60f9a049a4d584996f73a", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/eb2a9537910b3a5040efc0a7860f8128d4b259aa", + "reference": "eb2a9537910b3a5040efc0a7860f8128d4b259aa", "shasum": "" }, "require": { @@ -6157,7 +5911,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.2.3" + "source": "https://github.com/symfony/var-dumper/tree/v7.2.8" }, "funding": [ { @@ -6173,20 +5927,20 @@ "type": "tidelift" } ], - "time": "2025-01-17T11:39:41+00:00" + "time": "2025-06-27T19:53:16+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.2.0", + "version": "v7.2.7", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "1a6a89f95a46af0f142874c9d650a6358d13070d" + "reference": "785cff5a2f878bdbc5301965c1271e839aeb9a10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/1a6a89f95a46af0f142874c9d650a6358d13070d", - "reference": "1a6a89f95a46af0f142874c9d650a6358d13070d", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/785cff5a2f878bdbc5301965c1271e839aeb9a10", + "reference": "785cff5a2f878bdbc5301965c1271e839aeb9a10", "shasum": "" }, "require": { @@ -6233,7 +5987,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.2.0" + "source": "https://github.com/symfony/var-exporter/tree/v7.2.7" }, "funding": [ { @@ -6249,20 +6003,20 @@ "type": "tidelift" } ], - "time": "2024-10-18T07:58:17+00:00" + "time": "2025-05-15T09:03:48+00:00" }, { "name": "symfony/yaml", - "version": "v7.2.3", + "version": "v7.2.8", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "ac238f173df0c9c1120f862d0f599e17535a87ec" + "reference": "262cbc0765a2fa4793efbdad500236dda66106b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/ac238f173df0c9c1120f862d0f599e17535a87ec", - "reference": "ac238f173df0c9c1120f862d0f599e17535a87ec", + "url": "https://api.github.com/repos/symfony/yaml/zipball/262cbc0765a2fa4793efbdad500236dda66106b1", + "reference": "262cbc0765a2fa4793efbdad500236dda66106b1", "shasum": "" }, "require": { @@ -6305,7 +6059,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.2.3" + "source": "https://github.com/symfony/yaml/tree/v7.2.8" }, "funding": [ { @@ -6321,7 +6075,7 @@ "type": "tidelift" } ], - "time": "2025-01-07T12:55:42+00:00" + "time": "2025-06-03T06:57:06+00:00" }, { "name": "twig/twig", 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..a64f299 --- /dev/null +++ b/terraform/cloudwatch.tf @@ -0,0 +1,162 @@ +# 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 +# Note: WAF log group names must start with "aws-waf-logs-" +resource "aws_cloudwatch_log_group" "waf" { + name = "aws-waf-logs-${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..7889e1c --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,37 @@ +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 + app_auth { + id = var.github_app_id + installation_id = var.github_app_installation_id + pem_file = var.github_app_pem_file + } +} \ 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..c4c343d --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,154 @@ +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" +} + +variable "github_app_id" { + description = "GitHub App ID" + type = string +} + +variable "github_app_installation_id" { + description = "GitHub App Installation ID" + type = string +} + +variable "github_app_pem_file" { + description = "GitHub App PEM file" + type = string +} 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 + ] +}