diff --git a/.github/workflows/test_terragrunt.yaml b/.github/workflows/test_terragrunt.yaml new file mode 100644 index 00000000..b5439273 --- /dev/null +++ b/.github/workflows/test_terragrunt.yaml @@ -0,0 +1,179 @@ +--- +name: Test Terragrunt + +on: + pull_request: # Plan. + paths: [tests/terragrunt/**, .github/workflows/test_terragrunt.yaml] + types: [opened, synchronize, reopened, labeled] + merge_group: # Apply. + types: [checks_requested] + workflow_dispatch: + inputs: + command: + description: 'Infrastructure command to run' + required: true + default: 'plan' + type: choice + options: + - plan + - apply + - destroy + +env: + TF_VERSION: "1.5.0" + TG_VERSION: "0.48.0" + AWS_REGION: "us-west-1" + +jobs: + Target: + runs-on: ubuntu-24.04 + + permissions: + issues: write # Required to add PR label. + pull-requests: write # Required to add PR comment. + + outputs: + targets: ${{ steps.changed.outputs.targets }} + + steps: + - name: Changed files + id: changed + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.number }} + run: | + # Add link to PR during apply job summary. + if [[ "${{ github.event_name }}" == "merge_group" ]]; then + PR_NUMBER=$(echo "${{ github.ref_name }}" | sed -n 's/.*pr-\([0-9]*\)-.*/\1/p') + echo "View PR [#${PR_NUMBER}](https://github.com/${{ github.repository }}/pull/${PR_NUMBER}) to review planned proposal." >> $GITHUB_STEP_SUMMARY + fi + # Remove "tg-plan" PR label if it exists. + if [[ "${{ github.event.action }}" == "labeled" ]]; then gh api /repos/${{ github.repository }}/issues/${PR_NUMBER}/labels/tg-plan --method DELETE --silent; fi + # Output changed targets. + changed=$(gh api /repos/${{ github.repository }}/pulls/${PR_NUMBER}/files --paginate --jq '.[].filename') + echo "targets=$(echo "$changed" | jq -R 'select(test("^tests/terragrunt/live/")) | split("/")[4]' | jq -c -s 'unique | sort')" >> $GITHUB_OUTPUT + + TG: + runs-on: ubuntu-24.04 + needs: [Target] + if: ${{ needs.Target.outputs.targets != '[]' || github.event_name == 'workflow_dispatch' }} + + permissions: + actions: read # Required to identify workflow run. + checks: write # Required to add status summary. + contents: read # Required to checkout repository. + id-token: write # Required to authenticate via OIDC. + issues: write # Required to add PR label. + pull-requests: write # Required to add PR comment. + + strategy: + fail-fast: false + matrix: + target: ${{ fromJson(needs.Target.outputs.targets) }} + + concurrency: + cancel-in-progress: false + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name}}-${{ matrix.target }} + + environment: ${{ matrix.target }} + + steps: + - name: Authenticate AWS + uses: aws-actions/configure-aws-credentials@a03048d87541d1d9fcf2ecf528a4a65ba9bd7838 # v5.0.0 + with: + aws-region: ${{ vars.AWS_REGION }} + role-to-assume: ${{ vars.AWS_ROLE }} + role-session-name: tg-via-pr-${{ github.run_id }}-${{ github.run_attempt }} + + - name: Authenticate GitHub + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.number }} + run: | + # Authenticate with GitHub token. + git config --global url."https://token:${GH_TOKEN}@github.com".insteadOf "https://github.com" + # Add the target name as a PR label if it does not exist. + if [[ "${{ github.event_name }}" == "pull_request" && "${{ !contains(github.event.pull_request.labels.*.name, matrix.target) }}" == "true" ]]; then + gh api /repos/${{ github.repository }}/issues/${PR_NUMBER}/labels --method POST --field "labels[]=${{ matrix.target }}" --silent + fi + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TF_VERSION }} + + - name: Setup Terragrunt + run: | + wget https://github.com/gruntwork-io/terragrunt/releases/download/v${{ env.TG_VERSION }}/terragrunt_linux_amd64 + chmod +x terragrunt_linux_amd64 + sudo mv terragrunt_linux_amd64 /usr/local/bin/terragrunt + + - name: Checkout PR + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Provision TG + id: tg + uses: ./ + with: + working-directory: tests/terragrunt/live/project/${{ matrix.target }} + tool: terragrunt + command: ${{ github.event_name == 'merge_group' && 'apply' || (github.event_name == 'workflow_dispatch' && github.event.inputs.command || 'plan') }} + arg-lock-timeout: 3m + plan-encrypt: secrets.TF_ENCRYPTION + plan-parity: true + retention-days: 1 + expand-diff: true + tag-actor: never + arg-auto-approve: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.command == 'apply' || github.event.inputs.command == 'destroy') }} + + - name: Troubleshoot TG + if: ${{ failure() && github.event_name == 'merge_group' }} + uses: op5dev/prompt-ai@4cacb93e4a1e101f3a89650b31a3582321f2461d # v2.0.0 + with: + model: openai/gpt-4.1-mini + system-prompt: You are a helpful DevOps assistant and expert at troubleshooting Terragrunt errors. + user-prompt: Troubleshoot the following Terragrunt output; ${{ steps.tg.outputs.result }} + + - name: Clear directory + if: ${{ failure() && github.event_name == 'merge_group' }} + run: find ${{ github.workspace }} -mindepth 1 -delete + + - name: Checkout main + if: ${{ failure() && github.event_name == 'merge_group' }} + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: main + persist-credentials: false + + - name: Rollback TG + if: ${{ failure() && github.event_name == 'merge_group' }} + uses: ./ + with: + working-directory: tests/terragrunt/live/project/${{ matrix.target }} + tool: terragrunt + command: apply + arg-auto-approve: true + arg-lock-timeout: 3m + comment-pr: never + +# Todo: Will uncomment when this workflow is fully implemented. + Notify: + runs-on: [ubuntu-24.04] + needs: [Target, TG] + if: ${{ !cancelled() }} + + permissions: + actions: read # Required to identify workflow run. + + steps: + - name: Notify Slack on failure + if: ${{ github.event_name == 'merge_group' && contains(needs.*.result, 'failure') }} + uses: gamesight/slack-workflow-status@68bf00d0dbdbcb206c278399aa1ef6c14f74347a # v1.3.0 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + slack_webhook_url: https://hooks.slack.com/services/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 + + - name: Exit status + run: exit ${{ contains(needs.*.result, 'failure') && 1 || 0 }} diff --git a/action.yml b/action.yml index bd4b3a23..64ac841a 100644 --- a/action.yml +++ b/action.yml @@ -1,7 +1,7 @@ --- -name: Terraform/OpenTofu via Pull Request +name: Terraform/OpenTofu/Terragrunt via Pull Request author: Rishav Dhar (@rdhar) -description: Plan and apply Terraform/OpenTofu via PR automation, using best practices for secure and scalable IaC workflows. +description: Plan and apply Terraform/OpenTofu/Terragrunt via PR automation, using best practices for secure and scalable IaC workflows. runs: using: composite @@ -17,7 +17,7 @@ runs: which jq > /dev/null 2>&1 || { echo "Please install jq before running this action as it is required for processing JSON outputs."; exit 1; } which md5sum > /dev/null 2>&1 || { echo "Please install md5sum before running this action as it is required for naming the plan file artifact uniquely."; exit 1; } which unzip > /dev/null 2>&1 || { echo "Please install unzip before running this action as it is required for unpacking the plan file artifact."; exit 1; } - which $INPUTS_TOOL > /dev/null 2>&1 || { echo "Please install $INPUTS_TOOL before running this action as it is required for provisioning TF code."; exit 1; } + which $INPUTS_TOOL > /dev/null 2>&1 || { echo "Please install $INPUTS_TOOL before running this action as it is required for provisioning infrastructure code."; exit 1; } if [[ "$INPUTS_PLAN_ENCRYPT" ]]; then which openssl > /dev/null 2>&1 || { echo "Please install openssl before running this action as it is required for plan file encryption."; exit 1; }; fi if [[ "$INPUTS_PLAN_PARITY" ]]; then which diff > /dev/null 2>&1 || { echo "Please install diff before running this action as it is required for comparing plan file parity."; exit 1; }; fi @@ -59,6 +59,7 @@ runs: INPUTS_ARG_VAR_FILE: ${{ inputs.arg-var-file }} INPUTS_ARG_VAR: ${{ inputs.arg-var }} INPUTS_ARG_WRITE: ${{ inputs.arg-write }} + INPUTS_ARG_BACKEND_BOOTSTRAP: ${{ inputs.arg-backend-bootstrap }} INPUTS_TOKEN: ${{ inputs.token }} TF_WORKSPACE: ${{ env.TF_WORKSPACE || inputs.arg-workspace }} shell: bash @@ -71,6 +72,7 @@ runs: echo "TF_IN_AUTOMATION=true" >> "$GITHUB_ENV" echo "TF_INPUT=false" >> "$GITHUB_ENV" echo "TF_WORKSPACE=$TF_WORKSPACE" >> "$GITHUB_ENV" + echo "TG_NON_INTERACTIVE=true" >> "$GITHUB_ENV" echo "GH_HOST=$(echo $GITHUB_SERVER_URL | sed 's/.*:\/\///')" >> "$GITHUB_ENV" # CLI arguments. @@ -111,6 +113,7 @@ runs: echo arg-var=$([[ -n "$INPUTS_ARG_VAR" ]] && echo " -var=$INPUTS_ARG_VAR" | sed "s/,/ -var=/g" || echo "") >> "$GITHUB_OUTPUT" echo arg-write=$([[ -n "$INPUTS_ARG_WRITE" ]] && echo " -write=$INPUTS_ARG_WRITE" || echo "") >> "$GITHUB_OUTPUT" echo arg-workspace=$([[ -n "$TF_WORKSPACE" ]] && echo " -workspace=$TF_WORKSPACE" || echo "") >> "$GITHUB_OUTPUT" + echo arg-backend-bootstrap=$([[ -n "$INPUTS_ARG_BACKEND_BOOTSTRAP" ]] && echo " --backend-bootstrap" || echo "") >> "$GITHUB_OUTPUT" - id: identifier env: @@ -137,9 +140,9 @@ runs: echo "pr=${pr_number:-0}" >> "$GITHUB_OUTPUT" # Generate identifier for the workflow run using MD5 hashing algorithm for concise and unique naming. - identifier="${{ steps.arg.outputs.arg-chdir }}${{ steps.arg.outputs.arg-workspace }}${{ steps.arg.outputs.arg-backend-config }}${{ steps.arg.outputs.arg-var-file }}${{ steps.arg.outputs.arg-var }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-target }}${{ steps.arg.outputs.arg-destroy }}" + identifier="${{ inputs.arg-chdir || inputs.working-directory }}${{ steps.arg.outputs.arg-workspace }}${{ steps.arg.outputs.arg-backend-config }}${{ steps.arg.outputs.arg-var-file }}${{ steps.arg.outputs.arg-var }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-target }}${{ steps.arg.outputs.arg-destroy }}" identifier=$(echo -n "$identifier" | md5sum | awk '{print $1}') - echo "name=${INPUTS_TOOL}-${pr_number}-${identifier}.tfplan" >> "$GITHUB_OUTPUT" + echo "name=${INPUTS_TOOL}-${pr_number}-${identifier}.plan" >> "$GITHUB_OUTPUT" - if: ${{ inputs.format == 'true' }} id: format @@ -147,11 +150,21 @@ runs: INPUTS_TOOL: ${{ inputs.tool }} shell: bash run: | - # TF format. + # Infrastructure format. trap 'exit_code="$?"; echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"' EXIT - args="${{ steps.arg.outputs.arg-check }}${{ steps.arg.outputs.arg-diff }}${{ steps.arg.outputs.arg-list }}${{ steps.arg.outputs.arg-recursive }}${{ steps.arg.outputs.arg-write }}" - echo "$INPUTS_TOOL fmt${{ steps.arg.outputs.arg-chdir }}${args}" | sed 's/ -/\n -/g' > tf.command.txt - $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} fmt${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt) + if [[ "$INPUTS_TOOL" == "terragrunt" ]]; then + # Terragrunt doesn't use -chdir, it changes directory first + if [[ -n "${{ inputs.arg-chdir || inputs.working-directory }}" ]]; then + cd "${{ inputs.arg-chdir || inputs.working-directory }}" + fi + args="${{ steps.arg.outputs.arg-check }}${{ steps.arg.outputs.arg-diff }}${{ steps.arg.outputs.arg-list }}${{ steps.arg.outputs.arg-recursive }}${{ steps.arg.outputs.arg-write }}" + echo "$INPUTS_TOOL fmt${args}" | sed 's/ -/\n -/g' | sed 's/ --/\n --/g' > tf.command.txt + $INPUTS_TOOL fmt${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt) + else + args="${{ steps.arg.outputs.arg-check }}${{ steps.arg.outputs.arg-diff }}${{ steps.arg.outputs.arg-list }}${{ steps.arg.outputs.arg-recursive }}${{ steps.arg.outputs.arg-write }}" + echo "$INPUTS_TOOL fmt${{ steps.arg.outputs.arg-chdir }}${args}" | sed 's/ -/\n -/g' | sed 's/ --/\n --/g' > tf.command.txt + $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} fmt${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt) + fi - if: ${{ contains(fromJSON('["plan", "apply", "init"]'), inputs.command) }} id: initialize @@ -161,9 +174,19 @@ runs: run: | # TF initialize. trap 'exit_code="$?"; echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"' EXIT - args="${{ steps.arg.outputs.arg-backend-config }}${{ steps.arg.outputs.arg-backend }}${{ env.INPUTS_TOOL == 'tofu' && steps.arg.outputs.arg-var-file || '' }}${{ env.INPUTS_TOOL == 'tofu' && steps.arg.outputs.arg-var || '' }}${{ steps.arg.outputs.arg-force-copy }}${{ steps.arg.outputs.arg-from-module }}${{ steps.arg.outputs.arg-get }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-lockfile }}${{ steps.arg.outputs.arg-migrate-state }}${{ steps.arg.outputs.arg-plugin-dir }}${{ steps.arg.outputs.arg-reconfigure }}${{ steps.arg.outputs.arg-test-directory }}${{ steps.arg.outputs.arg-upgrade }}" - echo "$INPUTS_TOOL init${{ steps.arg.outputs.arg-chdir }}${args}" | sed 's/ -/\n -/g' > tf.command.txt - $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} init${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt) + if [[ "$INPUTS_TOOL" == "terragrunt" ]]; then + # Terragrunt doesn't use -chdir, it changes directory first + if [[ -n "${{ inputs.arg-chdir || inputs.working-directory }}" ]]; then + cd "${{ inputs.arg-chdir || inputs.working-directory }}" + fi + args="${{ steps.arg.outputs.arg-backend-config }}${{ steps.arg.outputs.arg-backend }}${{ steps.arg.outputs.arg-backend-bootstrap }}${{ steps.arg.outputs.arg-force-copy }}${{ steps.arg.outputs.arg-from-module }}${{ steps.arg.outputs.arg-get }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-lockfile }}${{ steps.arg.outputs.arg-migrate-state }}${{ steps.arg.outputs.arg-plugin-dir }}${{ steps.arg.outputs.arg-reconfigure }}${{ steps.arg.outputs.arg-test-directory }}${{ steps.arg.outputs.arg-upgrade }}" + echo "$INPUTS_TOOL init${args}" | sed 's/ --/\n --/g' | sed 's/ -/\n -/g' > tf.command.txt + $INPUTS_TOOL init${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt) + else + args="${{ steps.arg.outputs.arg-backend-config }}${{ steps.arg.outputs.arg-backend }}${{ env.INPUTS_TOOL == 'tofu' && steps.arg.outputs.arg-var-file || '' }}${{ env.INPUTS_TOOL == 'tofu' && steps.arg.outputs.arg-var || '' }}${{ steps.arg.outputs.arg-force-copy }}${{ steps.arg.outputs.arg-from-module }}${{ steps.arg.outputs.arg-get }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-lockfile }}${{ steps.arg.outputs.arg-migrate-state }}${{ steps.arg.outputs.arg-plugin-dir }}${{ steps.arg.outputs.arg-reconfigure }}${{ steps.arg.outputs.arg-test-directory }}${{ steps.arg.outputs.arg-upgrade }}" + echo "$INPUTS_TOOL init${{ steps.arg.outputs.arg-chdir }}${args}" | sed 's/ -/\n -/g' | sed 's/ --/\n --/g' > tf.command.txt + $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} init${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt) + fi - if: ${{ inputs.validate == 'true' && contains(fromJSON('["plan", "apply", "init"]'), inputs.command) }} id: validate @@ -171,11 +194,21 @@ runs: INPUTS_TOOL: ${{ inputs.tool }} shell: bash run: | - # TF validate. + # Infrastructure validate. trap 'exit_code="$?"; echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"' EXIT - args="${{ env.INPUTS_TOOL == 'tofu' && steps.arg.outputs.arg-var-file || '' }}${{ env.INPUTS_TOOL == 'tofu' && steps.arg.outputs.arg-var || '' }}${{ steps.arg.outputs.arg-no-tests }}${{ steps.arg.outputs.arg-test-directory }}" - echo "$INPUTS_TOOL validate${{ steps.arg.outputs.arg-chdir }}${args}" | sed 's/ -/\n -/g' > tf.command.txt - $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} validate${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt) + if [[ "$INPUTS_TOOL" == "terragrunt" ]]; then + # Terragrunt doesn't use -chdir, it changes directory first + if [[ -n "${{ inputs.arg-chdir || inputs.working-directory }}" ]]; then + cd "${{ inputs.arg-chdir || inputs.working-directory }}" + fi + args="${{ steps.arg.outputs.arg-no-tests }}${{ steps.arg.outputs.arg-test-directory }}" + echo "$INPUTS_TOOL validate${args}" | sed 's/ -/\n -/g' | sed 's/ --/\n --/g' > tf.command.txt + $INPUTS_TOOL validate${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt) + else + args="${{ env.INPUTS_TOOL == 'tofu' && steps.arg.outputs.arg-var-file || '' }}${{ env.INPUTS_TOOL == 'tofu' && steps.arg.outputs.arg-var || '' }}${{ steps.arg.outputs.arg-no-tests }}${{ steps.arg.outputs.arg-test-directory }}" + echo "$INPUTS_TOOL validate${{ steps.arg.outputs.arg-chdir }}${args}" | sed 's/ -/\n -/g' | sed 's/ --/\n --/g' > tf.command.txt + $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} validate${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt) + fi - if: ${{ inputs.command == 'plan' }} id: plan @@ -185,17 +218,29 @@ runs: path: ${{ format('{0}{1}tfplan', inputs.arg-chdir || inputs.working-directory, (inputs.arg-chdir || inputs.working-directory) && '/' || '') }} shell: bash run: | - # TF plan. + # Infrastructure plan. trap 'exit_code="$?"; echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"; if [[ "$exit_code" == "2" ]]; then exit 0; fi' EXIT - args="${{ steps.arg.outputs.arg-destroy }}${{ steps.arg.outputs.arg-var-file }}${{ steps.arg.outputs.arg-var }}${{ steps.arg.outputs.arg-compact-warnings }}${{ steps.arg.outputs.arg-concise }}${{ steps.arg.outputs.arg-detailed-exitcode }}${{ steps.arg.outputs.arg-generate-config-out }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-parallelism }}${{ steps.arg.outputs.arg-refresh-only }}${{ steps.arg.outputs.arg-refresh }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-target }} -out=tfplan" - echo "$INPUTS_TOOL plan${{ steps.arg.outputs.arg-chdir }}${args}" | sed 's/ -/\n -/g' > tf.command.txt - if [[ -n "$INPUTS_PLAN_FILE" ]]; then mv --force --verbose "$INPUTS_PLAN_FILE" "$path" 2>/dev/null && exit 0; fi - $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} plan${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt) + if [[ "$INPUTS_TOOL" == "terragrunt" ]]; then + # Terragrunt doesn't use -chdir, it changes directory first + if [[ -n "${{ inputs.arg-chdir || inputs.working-directory }}" ]]; then + cd "${{ inputs.arg-chdir || inputs.working-directory }}" + fi + args="${{ steps.arg.outputs.arg-destroy }}${{ steps.arg.outputs.arg-compact-warnings }}${{ steps.arg.outputs.arg-concise }}${{ steps.arg.outputs.arg-detailed-exitcode }}${{ steps.arg.outputs.arg-generate-config-out }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-parallelism }}${{ steps.arg.outputs.arg-refresh-only }}${{ steps.arg.outputs.arg-refresh }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-target }} -out=tfplan" + echo "$INPUTS_TOOL plan${args}" | sed 's/ -/\n -/g' | sed 's/ --/\n --/g' > tf.command.txt + if [[ -n "$INPUTS_PLAN_FILE" ]]; then mv --force --verbose "$INPUTS_PLAN_FILE" "$path" 2>/dev/null && exit 0; fi + $INPUTS_TOOL plan${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt) + else + args="${{ steps.arg.outputs.arg-destroy }}${{ steps.arg.outputs.arg-var-file }}${{ steps.arg.outputs.arg-var }}${{ steps.arg.outputs.arg-compact-warnings }}${{ steps.arg.outputs.arg-concise }}${{ steps.arg.outputs.arg-detailed-exitcode }}${{ steps.arg.outputs.arg-generate-config-out }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-parallelism }}${{ steps.arg.outputs.arg-refresh-only }}${{ steps.arg.outputs.arg-refresh }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-target }} -out=tfplan" + echo "$INPUTS_TOOL plan${{ steps.arg.outputs.arg-chdir }}${args}" | sed 's/ -/\n -/g' | sed 's/ --/\n --/g' > tf.command.txt + if [[ -n "$INPUTS_PLAN_FILE" ]]; then mv --force --verbose "$INPUTS_PLAN_FILE" "$path" 2>/dev/null && exit 0; fi + $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} plan${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt) + fi - if: ${{ inputs.command == 'apply' && inputs.arg-auto-approve != 'true' && inputs.plan-file == '' }} id: download env: INPUTS_ARG_CHDIR: ${{ inputs.arg-chdir || inputs.working-directory }} + INPUTS_TOOL: ${{ inputs.tool }} shell: bash run: | # Download plan file. @@ -205,7 +250,12 @@ runs: gh api /repos/${{ github.repository }}/actions/artifacts/${artifact_id}/zip --header "$GH_API" --method GET > "${{ steps.identifier.outputs.name }}.zip" # Unzip the plan file to the working directory, then clean up the zip file. - unzip "${{ steps.identifier.outputs.name }}.zip" -d "$INPUTS_ARG_CHDIR" + if [[ "$INPUTS_TOOL" == "terragrunt" ]]; then + # For Terragrunt, unzip to the current directory since we'll cd into the working directory + unzip "${{ steps.identifier.outputs.name }}.zip" + else + unzip "${{ steps.identifier.outputs.name }}.zip" -d "$INPUTS_ARG_CHDIR" + fi rm --force "${{ steps.identifier.outputs.name }}.zip" - if: ${{ inputs.plan-encrypt != '' && steps.download.outcome == 'success' }} @@ -226,7 +276,15 @@ runs: shell: bash run: | # TF show. - $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} show tfplan > tf.console.txt + if [[ "$INPUTS_TOOL" == "terragrunt" ]]; then + # Terragrunt doesn't use -chdir, it changes directory first + if [[ -n "${{ inputs.arg-chdir || inputs.working-directory }}" ]]; then + cd "${{ inputs.arg-chdir || inputs.working-directory }}" + fi + $INPUTS_TOOL show tfplan > tf.console.txt + else + $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} show tfplan > tf.console.txt + fi # Diff of changes. # Filter lines starting with " # " and save to tf.diff.txt, then prepend diff-specific symbols based on specific keywords. @@ -280,14 +338,24 @@ runs: path: ${{ format('{0}{1}tfplan', inputs.arg-chdir || inputs.working-directory, (inputs.arg-chdir || inputs.working-directory) && '/' || '') }} shell: bash run: | - # TF plan parity. + # Infrastructure plan parity. # Generate a new plan file, then compare it with the previous one. # Both plan files are normalized by sorting JSON keys, removing timestamps and ${{ steps.arg.outputs.arg-detailed-exitcode }} to avoid false-positives. if [[ -n "$INPUTS_PLAN_FILE" ]]; then mv --force --verbose "$INPUTS_PLAN_FILE" "$path" 2>/dev/null; fi - $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} plan${{ steps.arg.outputs.arg-destroy }}${{ steps.arg.outputs.arg-var-file }}${{ steps.arg.outputs.arg-var }}${{ steps.arg.outputs.arg-compact-warnings }}${{ steps.arg.outputs.arg-concise }}${{ steps.arg.outputs.arg-generate-config-out }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-parallelism }}${{ steps.arg.outputs.arg-refresh-only }}${{ steps.arg.outputs.arg-refresh }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-target }} -out=tfplan.parity - $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} show -json tfplan.parity | jq --sort-keys '[(.resource_changes? // [])[] | select(.change.actions != ["no-op"])]' > tfplan.new - $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} show -json tfplan | jq --sort-keys '[(.resource_changes? // [])[] | select(.change.actions != ["no-op"])]' > tfplan.old - # If both plan files are identical, then replace the old plan file with the new one to prevent avoidable stale apply. + if [[ "$INPUTS_TOOL" == "terragrunt" ]]; then + # Terragrunt doesn't use -chdir, it changes directory first + if [[ -n "${{ inputs.arg-chdir || inputs.working-directory }}" ]]; then + cd "${{ inputs.arg-chdir || inputs.working-directory }}" + fi + $INPUTS_TOOL plan${{ steps.arg.outputs.arg-destroy }}${{ steps.arg.outputs.arg-compact-warnings }}${{ steps.arg.outputs.arg-concise }}${{ steps.arg.outputs.arg-generate-config-out }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-parallelism }}${{ steps.arg.outputs.arg-refresh-only }}${{ steps.arg.outputs.arg-refresh }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-target }} -out=tfplan.parity + $INPUTS_TOOL show -json tfplan.parity | jq --sort-keys '[(.resource_changes? // [])[] | select(.change.actions != ["no-op"])]' > tfplan.new + $INPUTS_TOOL show -json tfplan | jq --sort-keys '[(.resource_changes? // [])[] | select(.change.actions != ["no-op"])]' > tfplan.old + else + $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} plan${{ steps.arg.outputs.arg-destroy }}${{ steps.arg.outputs.arg-var-file }}${{ steps.arg.outputs.arg-var }}${{ steps.arg.outputs.arg-compact-warnings }}${{ steps.arg.outputs.arg-concise }}${{ steps.arg.outputs.arg-generate-config-out }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-parallelism }}${{ steps.arg.outputs.arg-refresh-only }}${{ steps.arg.outputs.arg-refresh }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-target }} -out=tfplan.parity + $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} show -json tfplan.parity | jq --sort-keys '[(.resource_changes? // [])[] | select(.change.actions != ["no-op"])]' > tfplan.new + $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} show -json tfplan | jq --sort-keys '[(.resource_changes? // [])[] | select(.change.actions != ["no-op"])]' > tfplan.old + fi + # If both plan files are identical, then replace the old plan file with the new one to prevent avoidable stale plans from being applied. diff --brief tfplan.new tfplan.old && mv --force --verbose "$parity_path" "$path" rm --force tfplan.new tfplan.old "$parity_path" @@ -301,22 +369,43 @@ runs: path: ${{ format('{0}{1}tfplan', inputs.arg-chdir || inputs.working-directory, (inputs.arg-chdir || inputs.working-directory) && '/' || '') }} shell: bash run: | - # TF apply. + # Infrastructure apply. trap 'exit_code="$?"; echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"' EXIT - # If arg-auto-approve is true, then pass in variables, otherwise pass in the plan file without variables. - if [[ "$INPUTS_ARG_AUTO_APPROVE" == "true" ]]; then - plan="${{ steps.arg.outputs.arg-auto-approve }}" - var_file="${{ steps.arg.outputs.arg-var-file }}" - var="${{ steps.arg.outputs.arg-var }}" + if [[ "$INPUTS_TOOL" == "terragrunt" ]]; then + # Terragrunt doesn't use -chdir, it changes directory first + if [[ -n "${{ inputs.arg-chdir || inputs.working-directory }}" ]]; then + cd "${{ inputs.arg-chdir || inputs.working-directory }}" + fi + # If arg-auto-approve is true, then pass in variables, otherwise pass in the plan file without variables. + if [[ "$INPUTS_ARG_AUTO_APPROVE" == "true" ]]; then + plan="${{ steps.arg.outputs.arg-auto-approve }}" + var_file="${{ steps.arg.outputs.arg-var-file }}" + var="${{ steps.arg.outputs.arg-var }}" + else + if [[ -n "$INPUTS_PLAN_FILE" && "$INPUTS_PLAN_PARITY" != "true" ]]; then mv --force --verbose "$INPUTS_PLAN_FILE" "$path" 2>/dev/null; fi + plan=" tfplan" + var_file="" + var="" + fi + args="${{ steps.arg.outputs.arg-destroy }}${var_file}${var}${{ steps.arg.outputs.arg-backup }}${{ steps.arg.outputs.arg-compact-warnings }}${{ steps.arg.outputs.arg-concise }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-parallelism }}${{ steps.arg.outputs.arg-refresh-only }}${{ steps.arg.outputs.arg-refresh }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-state-out }}${{ steps.arg.outputs.arg-state }}${{ steps.arg.outputs.arg-target }}${plan}" + echo "$INPUTS_TOOL apply${args}" | sed 's/ -/\n -/g' | sed 's/ --/\n --/g' > tf.command.txt + $INPUTS_TOOL apply${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt) else - if [[ -n "$INPUTS_PLAN_FILE" && "$INPUTS_PLAN_PARITY" != "true" ]]; then mv --force --verbose "$INPUTS_PLAN_FILE" "$path" 2>/dev/null; fi - plan=" tfplan" - var_file="" - var="" + # If arg-auto-approve is true, then pass in variables, otherwise pass in the plan file without variables. + if [[ "$INPUTS_ARG_AUTO_APPROVE" == "true" ]]; then + plan="${{ steps.arg.outputs.arg-auto-approve }}" + var_file="${{ steps.arg.outputs.arg-var-file }}" + var="${{ steps.arg.outputs.arg-var }}" + else + if [[ -n "$INPUTS_PLAN_FILE" && "$INPUTS_PLAN_PARITY" != "true" ]]; then mv --force --verbose "$INPUTS_PLAN_FILE" "$path" 2>/dev/null; fi + plan=" tfplan" + var_file="" + var="" + fi + args="${{ steps.arg.outputs.arg-destroy }}${var_file}${var}${{ steps.arg.outputs.arg-backup }}${{ steps.arg.outputs.arg-compact-warnings }}${{ steps.arg.outputs.arg-concise }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-parallelism }}${{ steps.arg.outputs.arg-refresh-only }}${{ steps.arg.outputs.arg-refresh }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-state-out }}${{ steps.arg.outputs.arg-state }}${{ steps.arg.outputs.arg-target }}${plan}" + echo "$INPUTS_TOOL apply${{ steps.arg.outputs.arg-chdir }}${args}" | sed 's/ -/\n -/g' | sed 's/ --/\n --/g' > tf.command.txt + $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} apply${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt) fi - args="${{ steps.arg.outputs.arg-destroy }}${var_file}${var}${{ steps.arg.outputs.arg-backup }}${{ steps.arg.outputs.arg-compact-warnings }}${{ steps.arg.outputs.arg-concise }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-parallelism }}${{ steps.arg.outputs.arg-refresh-only }}${{ steps.arg.outputs.arg-refresh }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-state-out }}${{ steps.arg.outputs.arg-state }}${{ steps.arg.outputs.arg-target }}${plan}" - echo "$INPUTS_TOOL apply${{ steps.arg.outputs.arg-chdir }}${args}" | sed 's/ -/\n -/g' > tf.command.txt - $INPUTS_TOOL${{ steps.arg.outputs.arg-chdir }} apply${args} 2> >(tee tf.console.txt) > >(tee tf.console.txt) - if: ${{ !cancelled() && steps.identifier.outcome == 'success' && contains(fromJSON('["plan", "apply", "init"]'), inputs.command) }} id: post @@ -332,10 +421,18 @@ runs: INPUTS_PRESERVE_PLAN: ${{ inputs.preserve-plan }} INPUTS_SHOW_ARGS: ${{ inputs.show-args }} INPUTS_TAG_ACTOR: ${{ inputs.tag-actor }} + INPUTS_TOOL: ${{ inputs.tool }} path: ${{ format('{0}{1}tfplan', inputs.arg-chdir || inputs.working-directory, (inputs.arg-chdir || inputs.working-directory) && '/' || '') }} shell: bash run: | # Post output. + # Terragrunt doesn't use -chdir, it changes directory first + if [[ "$INPUTS_TOOL" == "terragrunt" ]]; then + if [[ -n "${{ inputs.arg-chdir || inputs.working-directory }}" ]]; then + cd "${{ inputs.arg-chdir || inputs.working-directory }}" + fi + fi + # Parse the "tf.command.txt" file. command=$(cat tf.command.txt) @@ -349,7 +446,7 @@ runs: command_append="" IFS=',' read -ra show_args <<< "$INPUTS_SHOW_ARGS" for arg in "${show_args[@]}"; do - command_append+=$(echo "${{ steps.arg.outputs.arg-workspace }}${{ steps.arg.outputs.arg-backend-config }}${{ steps.arg.outputs.arg-backend }}${{ steps.arg.outputs.arg-backup }}${{ steps.arg.outputs.arg-check }}${{ steps.arg.outputs.arg-compact-warnings }}${{ steps.arg.outputs.arg-concise }}${{ steps.arg.outputs.arg-destroy }}${{ steps.arg.outputs.arg-detailed-exitcode }}${{ steps.arg.outputs.arg-diff }}${{ steps.arg.outputs.arg-force-copy }}${{ steps.arg.outputs.arg-from-module }}${{ steps.arg.outputs.arg-generate-config-out }}${{ steps.arg.outputs.arg-get }}${{ steps.arg.outputs.arg-list }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-lockfile }}${{ steps.arg.outputs.arg-migrate-state }}${{ steps.arg.outputs.arg-no-tests }}${{ steps.arg.outputs.arg-parallelism }}${{ steps.arg.outputs.arg-plugin-dir }}${{ steps.arg.outputs.arg-reconfigure }}${{ steps.arg.outputs.arg-recursive }}${{ steps.arg.outputs.arg-refresh-only }}${{ steps.arg.outputs.arg-refresh }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-state-out }}${{ steps.arg.outputs.arg-state }}${{ steps.arg.outputs.arg-target }}${{ steps.arg.outputs.arg-test-directory }}${{ steps.arg.outputs.arg-upgrade }}${{ steps.arg.outputs.arg-var-file }}${{ steps.arg.outputs.arg-var }}${{ steps.arg.outputs.arg-write }}${{ steps.arg.outputs.arg-auto-approve }}" | sed 's/ -/\n -/g' | grep "^ -${arg}" || true) + command_append+=$(echo "${{ steps.arg.outputs.arg-workspace }}${{ steps.arg.outputs.arg-backend-config }}${{ steps.arg.outputs.arg-backend }}${{ steps.arg.outputs.arg-backup }}${{ steps.arg.outputs.arg-check }}${{ steps.arg.outputs.arg-compact-warnings }}${{ steps.arg.outputs.arg-concise }}${{ steps.arg.outputs.arg-destroy }}${{ steps.arg.outputs.arg-detailed-exitcode }}${{ steps.arg.outputs.arg-diff }}${{ steps.arg.outputs.arg-force-copy }}${{ steps.arg.outputs.arg-from-module }}${{ steps.arg.outputs.arg-generate-config-out }}${{ steps.arg.outputs.arg-get }}${{ steps.arg.outputs.arg-list }}${{ steps.arg.outputs.arg-lock-timeout }}${{ steps.arg.outputs.arg-lock }}${{ steps.arg.outputs.arg-lockfile }}${{ steps.arg.outputs.arg-migrate-state }}${{ steps.arg.outputs.arg-no-tests }}${{ steps.arg.outputs.arg-parallelism }}${{ steps.arg.outputs.arg-plugin-dir }}${{ steps.arg.outputs.arg-reconfigure }}${{ steps.arg.outputs.arg-recursive }}${{ steps.arg.outputs.arg-refresh-only }}${{ steps.arg.outputs.arg-refresh }}${{ steps.arg.outputs.arg-replace }}${{ steps.arg.outputs.arg-state-out }}${{ steps.arg.outputs.arg-state }}${{ steps.arg.outputs.arg-target }}${{ steps.arg.outputs.arg-test-directory }}${{ steps.arg.outputs.arg-upgrade }}${{ steps.arg.outputs.arg-var-file }}${{ steps.arg.outputs.arg-var }}${{ steps.arg.outputs.arg-write }}${{ steps.arg.outputs.arg-auto-approve }}" | sed 's/ -/\n -/g' | sed 's/ --/\n --/g' | grep "^ -${arg}" || true) done # Consolidate 'command', taking both "hide-args" and "show-args" into account. @@ -362,7 +459,12 @@ runs: echo "result<> "$GITHUB_OUTPUT" # Parse the "tf.console.txt" file for the summary. - summary=$(awk '/^(Error:|Plan:|Apply complete!|No changes.|Success)/ {line=$0} END {if (line) print line; else print "View output."}' tf.console.txt) + if [[ "$INPUTS_TOOL" == "terragrunt" ]]; then + # Terragrunt wraps output with ANSI codes and prefixes, so we need to strip them + summary=$(awk '/Plan: [0-9]+ to add/ {line=$0} /Apply complete!/ {line=$0} /No changes\./ {line=$0} /Error:/ {line=$0} /Success/ {line=$0} END {if (line) print line; else print "View output."}' tf.console.txt | sed 's/\x1b\[[0-9;]*m//g' | sed 's/.*terraform: //') + else + summary=$(awk '/^(Error:|Plan:|Apply complete!|No changes.|Success)/ {line=$0} END {if (line) print line; else print "View output."}' tf.console.txt) + fi echo "summary=$summary" >> "$GITHUB_OUTPUT" # If "steps.format.outcome" failed, set syntax highlighting to "diff", otherwise set it to "hcl". @@ -526,7 +628,7 @@ outputs: description: "ID of the check run." value: ${{ steps.post.outputs.check_id }} command: - description: "Input of the last TF command." + description: "Input of the last infrastructure command." value: ${{ steps.post.outputs.command }} comment-body: description: "Body of the PR comment." @@ -538,7 +640,7 @@ outputs: description: "Diff of changes, if present (truncated)." value: ${{ steps.post.outputs.diff }} exitcode: - description: "Exit code of the last TF command." + description: "Exit code of the last infrastructure command." value: ${{ steps.apply.outputs.exit_code || steps.plan.outputs.exit_code || steps.validate.outputs.exit_code || steps.initialize.outputs.exit_code || steps.format.outputs.exit_code }} identifier: description: "Unique name of the workflow run and artifact." @@ -553,13 +655,13 @@ outputs: description: "URL of the plan file artifact." value: ${{ steps.upload.outputs.artifact-url || steps.upload-v3.outputs.artifact-url }} result: - description: "Result of the last TF command (truncated)." + description: "Result of the last infrastructure command (truncated)." value: ${{ steps.post.outputs.result }} run-url: description: "URL of the workflow run." value: ${{ steps.post.outputs.run_url }} summary: - description: "Summary of the last TF command." + description: "Summary of the last infrastructure command." value: ${{ steps.post.outputs.summary }} inputs: @@ -586,7 +688,7 @@ inputs: required: false format: default: "false" - description: "Check format of TF code (e.g., `false`)." + description: "Check format of infrastructure code (e.g., `false`)." required: false hide-args: default: "detailed-exitcode,parallelism,lock,out,var=" @@ -630,7 +732,7 @@ inputs: required: false tool: default: "terraform" - description: "Provisioning tool to use between: `terraform` or `tofu` (e.g., `terraform`)." + description: "Provisioning tool to use between: `terraform`, `tofu`, or `terragrunt` (e.g., `terraform`)." required: false upload-plan: default: "true" @@ -638,11 +740,11 @@ inputs: required: false validate: default: "false" - description: "Check validation of TF code (e.g., `false`)." + description: "Check validation of infrastructure code (e.g., `false`)." required: false working-directory: default: "" - description: "Specify the working directory of TF code, alias of `arg-chdir` (e.g., `stacks/dev`)." + description: "Specify the working directory of infrastructure code, alias of `arg-chdir` (e.g., `stacks/dev`)." required: false # CLI arguments. @@ -658,6 +760,10 @@ inputs: default: "" description: "backend" required: false + arg-backend-bootstrap: + default: "" + description: "Enable backend bootstrap for Terragrunt (adds --backend-bootstrap). Set to 'true' to enable." + required: false arg-backup: default: "" description: "backup" diff --git a/tests/terragrunt/.gitignore b/tests/terragrunt/.gitignore new file mode 100644 index 00000000..c01f63c2 --- /dev/null +++ b/tests/terragrunt/.gitignore @@ -0,0 +1,77 @@ +# Local .terraform directories +.terraform/ + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data, such as +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject +# to change depending on the environment. +*.tfvars +*.tfvars.json + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Ignore transient lock info files created by terraform apply +.terraform.tfstate.lock.info + +# Include override files you do wish to add to version control using negated pattern +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + +# Ignore CLI configuration files +.terraformrc +terraform.rc + +# Terragrunt cache and debug files +.terragrunt-cache/ +.terragrunt-cache/** +terragrunt-debug.tfvars.json +terragrunt-debug.tfr + +# Terragrunt generated files +backend.tf +provider.tf + +# Additional Terragrunt files to ignore +.terragrunt-cache-* +terragrunt.log +terragrunt.log.* +*.terragrunt-cache +.terragrunt-cache-* +terragrunt-debug.log +terragrunt-debug.log.* +.terraform.lock.hcl + +# Terraform plan files +*.tfplan +*tfplan* + +# venv +venv/ +.venv/ +.env/ +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Lock files +package-lock.json +yarn.lock + +# Common directory for any VSCode language extensions +.vscode/ diff --git a/tests/terragrunt/.pre-commit-config.yaml b/tests/terragrunt/.pre-commit-config.yaml new file mode 100644 index 00000000..520caf4d --- /dev/null +++ b/tests/terragrunt/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-merge-conflict + + - repo: https://github.com/antonbabenko/pre-commit-terraform + rev: v1.90.0 + hooks: + - id: terraform_fmt + - id: terraform_validate + - id: terraform_tflint + args: + - --args=--enable-rule=terraform_documented_variables + - --args=--enable-rule=terraform_naming_convention diff --git a/tests/terragrunt/README.md b/tests/terragrunt/README.md new file mode 100644 index 00000000..6a6c9d41 --- /dev/null +++ b/tests/terragrunt/README.md @@ -0,0 +1,310 @@ +# Terragrunt Multi-Level Test Examples + +This directory contains comprehensive examples for testing Terragrunt `plan` and `apply` operations on multi-level infrastructure deployments across nonprod and prod AWS accounts. + +## Architecture Overview + +The test structure demonstrates a multi-level Terragrunt deployment with: + +- **Root Configuration**: Backend and common settings (root.hcl) +- **Common Configurations**: Shared settings and provider configs (_envcommon/) +- **Account Management**: Account-specific configurations (accounts.hcl) +- **Environment Level**: Environment-specific configurations (nonprod, prod) +- **Component Level**: Individual infrastructure components (VPC, EC2, S3) with provider config + +## Directory Structure + +``` +tests/terragrunt/ +├── root.hcl # Root configuration (includes common) +├── accounts.hcl # Account configuration for all environments +├── _envcommon/ # Common configurations +│ ├── common.hcl # Shared configuration, tags, and backend settings +│ └── providers/ +│ └── aws.hcl # AWS provider configuration +├── live/ # Live infrastructure configurations +│ └── project/ # Project-specific infrastructure +│ ├── nonprod/ +│ │ ├── environment.hcl # Non-prod environment config +│ │ ├── vpc/ +│ │ │ └── terragrunt.hcl # VPC component +│ │ ├── ec2/ +│ │ │ └── terragrunt.hcl # EC2 instances component +│ │ └── s3/ +│ │ └── terragrunt.hcl # S3 buckets component +│ └── prod/ +│ ├── environment.hcl # Production environment config +│ ├── vpc/ +│ │ └── terragrunt.hcl # VPC component (placeholder) +│ ├── ec2/ +│ │ └── (placeholder) # EC2 instances component (placeholder) +│ └── s3/ +│ └── terragrunt.hcl # S3 buckets component +├── .pre-commit-config.yaml # Pre-commit hooks configuration +├── .gitignore # Git ignore patterns +├── verify-config.sh # Configuration verification script +└── README.md # This file +``` + +## Prerequisites + +1. **Terragrunt**: Install Terragrunt (v0.48+ recommended, v0.54+ also supported) +2. **AWS CLI**: Configured with access to the `nonprod` AWS account (ID: 155524221786) +3. **Terraform**: v1.5+ (will be downloaded automatically by Terragrunt) + +## Quick Verification + +Run the verification script to ensure your configuration is correct: + +```bash +cd tests/terragrunt +./verify-config.sh +``` + +## AWS Account Configuration + +The examples use multiple AWS accounts for different environments: + +### Non-Production Environment +- **Account ID**: 155524221786 +- **Region**: us-west-1 +- **S3 Backend**: terragrunt-state-tf-via-pr-test-nonprod +- **DynamoDB**: terragrunt-locks-tf-via-pr-test-nonprod (for state locking) + +### Production Environment +- **Account ID**: 813676077823 +- **Region**: us-west-1 +- **S3 Backend**: terragrunt-state-tf-via-pr-test-prod +- **DynamoDB**: terragrunt-locks-tf-via-pr-test-prod (for state locking) + +## Usage Examples + +### 1. Initialize and Plan VPC Component + +```bash +cd tests/terragrunt/live/project/nonprod/vpc + +# Initialize Terragrunt +terragrunt init + +# Plan the VPC deployment +terragrunt plan + +# Apply the VPC deployment +terragrunt apply +``` + +### 2. Deploy Complete Non-Production Environment + +```bash +cd tests/terragrunt/live/project/nonprod + +# Deploy VPC first (dependency) +cd vpc +terragrunt apply + +# Deploy EC2 instances (depends on VPC) +cd ../ec2 +terragrunt apply + +# Deploy S3 buckets +cd ../s3 +terragrunt apply +``` + +### 3. Deploy Production Environment + +```bash +cd tests/terragrunt/live/project/prod + +# Deploy S3 buckets (currently the only active component) +cd s3 +terragrunt apply + +# Note: VPC and EC2 components are placeholders in prod environment +``` + +### 4. Run Plan on All Components + +```bash +# From the root terragrunt directory +cd tests/terragrunt + +# Plan all components in non-prod environment +terragrunt run-all plan --terragrunt-working-dir live/project/nonprod + +# Plan all components in prod environment +terragrunt run-all plan --terragrunt-working-dir live/project/prod +``` + +### 5. Apply All Components + +```bash +# Apply all components in non-prod environment +terragrunt run-all apply --terragrunt-working-dir live/project/nonprod + +# Apply all components in prod environment +terragrunt run-all apply --terragrunt-working-dir live/project/prod +``` + +### 6. GitHub Actions Integration + +This project includes a GitHub Actions workflow (`.github/workflows/terragrunt-live-all.yml`) that can: + +- Automatically discover all Terragrunt configurations +- Run `plan`, `apply`, or `destroy` across all components +- Support matrix builds for parallel execution +- Use OIDC for secure AWS authentication + +**Note**: The workflow is located at the repository root level (`.github/workflows/`), not in the terragrunt test directory. + +Trigger the workflow manually with: +- **Command**: Choose from `plan`, `apply`, or `destroy` + +## Module Sources + +The examples use official Terraform AWS modules: + +- **VPC**: [terraform-aws-modules/vpc/aws](https://github.com/terraform-aws-modules/terraform-aws-vpc) +- **EC2**: [terraform-aws-modules/ec2-instance/aws](https://github.com/terraform-aws-modules/terraform-aws-ec2-instance) +- **S3**: [terraform-aws-modules/s3-bucket/aws](https://github.com/terraform-aws-modules/terraform-aws-s3-bucket) + +## Key Features Demonstrated + +1. **Official Modules**: All components use maintained terraform-aws-modules +2. **Provider Management**: Centralized AWS provider configuration in `_envcommon/providers/aws.hcl` +3. **Dependency Management**: EC2 instances depend on VPC resources with mock outputs for planning +4. **Environment Separation**: Different configurations and accounts for nonprod and prod +5. **State Management**: Environment-specific S3 backends with DynamoDB locking +6. **Account Management**: Centralized account configuration with assume role support +7. **Tagging Strategy**: Consistent tagging across all resources with environment-specific tags +8. **CI/CD Integration**: GitHub Actions workflow for automated Terragrunt operations +9. **Security**: Encrypted state files, secure S3 backends, proper IAM role assumptions + +## Testing Commands + +### Configuration Verification + +First, verify your configuration is correct: + +```bash +cd tests/terragrunt +./verify-config.sh +``` + +### Basic Testing + +```bash +# Test plan on non-prod VPC +cd tests/terragrunt/live/project/nonprod/vpc +terragrunt plan + +# Test plan on non-prod EC2 +cd ../ec2 +terragrunt plan + +# Test plan on non-prod S3 +cd ../s3 +terragrunt plan +``` + +### Advanced Testing + +```bash +# Test dependency resolution (EC2 depends on VPC) +cd tests/terragrunt/live/project/nonprod/ec2 +terragrunt plan + +# Test environment-specific variables and account configuration +cd ../../prod/s3 +terragrunt plan + +# Test root configuration inheritance (root.hcl) +cd ../../nonprod/vpc +terragrunt plan + +# Test configuration validation +terragrunt validate-inputs +``` + +## Cleanup + +To destroy all resources: + +```bash +# Destroy in reverse dependency order for non-prod +cd tests/terragrunt/live/project/nonprod +cd s3 && terragrunt destroy +cd ../ec2 && terragrunt destroy +cd ../vpc && terragrunt destroy + +# Destroy in reverse dependency order for prod +cd ../../prod +cd s3 && terragrunt destroy +# Note: VPC and EC2 components are placeholders in prod + +# Or use run-all for each environment (destroys in correct order) +cd tests/terragrunt +terragrunt run-all destroy --terragrunt-working-dir live/project/nonprod +terragrunt run-all destroy --terragrunt-working-dir live/project/prod + +# Or use GitHub Actions workflow with 'destroy' command +``` + +## Troubleshooting + +### Common Issues + +1. **State Lock**: If operations fail, check DynamoDB for stuck locks +2. **Permissions**: Ensure AWS credentials have necessary permissions +3. **Dependencies**: Always deploy VPC before EC2 instances +4. **Module Versions**: Check for module version compatibility + +### Debug Commands + +```bash +# Show Terragrunt configuration +terragrunt show-config + +# Show dependency graph +terragrunt graph-dependencies + +# Validate configuration +terragrunt validate-inputs +``` + +## Security Notes + +- All resources are tagged appropriately +- S3 buckets have encryption and lifecycle policies +- EC2 instances use security groups with minimal required access +- VPC resources are properly isolated +- State files are encrypted and stored securely + +## Cost Considerations + +- Non-prod environment uses cost-effective instance types +- Prod environment has production-grade configurations +- S3 lifecycle policies help manage storage costs +- All resources are tagged for cost tracking and environment identification +- Separate accounts provide cost isolation and better financial management + +## Configuration Files Overview + +### Core Configuration Files + +- **`root.hcl`**: Root Terragrunt configuration with backend settings and global inputs +- **`accounts.hcl`**: Centralized account configuration for all environments +- **`_envcommon/common.hcl`**: Shared configuration values and backend settings +- **`_envcommon/providers/aws.hcl`**: Centralized AWS provider configuration +- **`environment.hcl`**: Environment-specific configurations and tags + +### Component Files + +Each component (`vpc`, `ec2`, `s3`) has its own `terragrunt.hcl` file that: +- Includes the root configuration +- Includes environment-specific configuration +- Includes the AWS provider configuration +- Defines dependencies (e.g., EC2 depends on VPC) +- Specifies the Terraform module source +- Provides component-specific inputs \ No newline at end of file diff --git a/tests/terragrunt/_envcommon/common.hcl b/tests/terragrunt/_envcommon/common.hcl new file mode 100644 index 00000000..b8dc9c01 --- /dev/null +++ b/tests/terragrunt/_envcommon/common.hcl @@ -0,0 +1,13 @@ +# _envcommon/common.hcl +# Centralized configuration values for all environments + +locals { + # Backend configuration + state_bucket = "terragrunt-state-tf-via-pr-test" + aws_region = "us-west-1" + + # Environment-specific bucket suffixes + bucket_suffixes = { + nonprod = "nonprod" + } +} diff --git a/tests/terragrunt/_envcommon/providers/aws.hcl b/tests/terragrunt/_envcommon/providers/aws.hcl new file mode 100644 index 00000000..001d0d99 --- /dev/null +++ b/tests/terragrunt/_envcommon/providers/aws.hcl @@ -0,0 +1,40 @@ +# _envcommon/providers/aws.hcl +# Centralized AWS provider generation per selected account + +locals { + # Select account by env var or default. Leaf stacks can override by defining local.account_name before including this file. + account_name_default = get_env("TG_ACCOUNT_NAME", get_env("ACCOUNT_NAME", "nonprod")) + account_name = local.account_name_default + + account_config = read_terragrunt_config(find_in_parent_folders("accounts.hcl")) + account = local.account_config.locals.accounts[local.account_name] +} + +# Ensure remote_state bucket account id is set for this run +terraform { + extra_arguments "env" { + commands = ["init", "plan", "apply", "destroy", "refresh"] + env_vars = { + TF_VAR_aws_account_id = local.account.aws_account_id + } + } +} + +# Generate the provider for the selected account +generate "provider" { + path = "provider.tf" + if_exists = "overwrite_terragrunt" + contents = <TF-via-PR Test - Non-Production Environment" > /var/www/html/index.html + EOF + ) + + # Root volume + root_block_device = { + volume_type = "gp3" + volume_size = 8 + encrypted = true + } + + # Volume tags + volume_tags = merge({ + Name = "tf-via-pr-test-ec2-root-nonprod" + }, try(include.environment.locals.environment_tags, {})) + + tags = merge( + { + Project = "tf-via-pr-test" + Environment = "nonprod" + ManagedBy = "Terragrunt" + Component = "ec2" + }, + try(include.environment.locals.environment_tags, {}) + ) +} diff --git a/tests/terragrunt/live/project/nonprod/environment.hcl b/tests/terragrunt/live/project/nonprod/environment.hcl new file mode 100644 index 00000000..9c944e3c --- /dev/null +++ b/tests/terragrunt/live/project/nonprod/environment.hcl @@ -0,0 +1,11 @@ +# Environment-specific configuration for nonprod +locals { + environment = "nonprod" + + # Environment-specific tags + environment_tags = { + Environment = "nonprod" + CostCenter = "development" + DataClass = "internal" + } +} diff --git a/tests/terragrunt/live/project/nonprod/s3/terragrunt.hcl b/tests/terragrunt/live/project/nonprod/s3/terragrunt.hcl new file mode 100644 index 00000000..b84abf1f --- /dev/null +++ b/tests/terragrunt/live/project/nonprod/s3/terragrunt.hcl @@ -0,0 +1,92 @@ +# TF-via-PR Test Project - Non-Production Environment - S3 Resource +include "root" { + path = find_in_parent_folders("root.hcl") +} + +# Include environment-specific configuration +include "environment" { + path = find_in_parent_folders("environment.hcl") +} + +locals { + account_name = "nonprod" + account_config = read_terragrunt_config(find_in_parent_folders("accounts.hcl")) + account = local.account_config.locals.accounts[local.account_name] +} + +# Include centralized AWS provider generator +include "aws_provider" { + path = find_in_parent_folders("_envcommon/providers/aws.hcl") +} + +terraform { + source = "git::https://github.com/terraform-aws-modules/terraform-aws-s3-bucket.git//?ref=v5.7.0" + + extra_arguments "env" { + commands = ["init", "plan", "apply", "destroy", "refresh"] + env_vars = { + TF_VAR_aws_account_id = local.account.aws_account_id + } + } +} + +inputs = { + # Provider/account + aws_region = local.account.aws_region + assume_role_arn = local.account.assume_role_arn + + bucket = "tf-via-pr-test-aws-shared-iacs3-nonprod" + acl = "private" + + control_object_ownership = true + object_ownership = "ObjectWriter" + + versioning = { + enabled = true + } + + server_side_encryption_configuration = { + rule = { + apply_server_side_encryption_by_default = { + sse_algorithm = "AES256" + } + } + } + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true + + # Lifecycle (shorter for nonprod) + lifecycle_rule = [ + { + id = "nonprod" + enabled = true + + transition = [ + { + days = 30 + storage_class = "STANDARD_IA" + }, + { + days = 90 + storage_class = "GLACIER" + } + ] + + expiration = { + days = 365 + } + } + ] + + tags = merge( + { + Project = "tf-via-pr-test" + Environment = "nonprod" + ManagedBy = "Terragrunt" + }, + try(include.environment.locals.environment_tags, {}) + ) +} diff --git a/tests/terragrunt/live/project/nonprod/vpc/terragrunt.hcl b/tests/terragrunt/live/project/nonprod/vpc/terragrunt.hcl new file mode 100644 index 00000000..22c38fcb --- /dev/null +++ b/tests/terragrunt/live/project/nonprod/vpc/terragrunt.hcl @@ -0,0 +1,77 @@ +# TF-via-PR Test Project - Non-Production Environment - VPC Resource +include "root" { + path = find_in_parent_folders("root.hcl") +} + +# Include environment-specific configuration +include "environment" { + path = find_in_parent_folders("environment.hcl") +} + +locals { + account_name = "nonprod" + account_config = read_terragrunt_config(find_in_parent_folders("accounts.hcl")) + account = local.account_config.locals.accounts[local.account_name] +} + +# Include centralized AWS provider generator +include "aws_provider" { + path = find_in_parent_folders("_envcommon/providers/aws.hcl") +} + +terraform { + source = "git::https://github.com/terraform-aws-modules/terraform-aws-vpc.git//?ref=v6.2.0" + + extra_arguments "env" { + commands = ["init", "plan", "apply", "destroy", "refresh"] + env_vars = { + TF_VAR_aws_account_id = local.account.aws_account_id + } + } +} + +inputs = { + # Provider/account + aws_region = local.account.aws_region + assume_role_arn = local.account.assume_role_arn + + name = "tf-via-pr-test-vpc-nonprod" + cidr = "10.0.0.0/16" + + azs = ["${local.account.aws_region}b", "${local.account.aws_region}c"] + private_subnets = ["10.0.1.0/24", "10.0.2.0/24"] + public_subnets = ["10.0.101.0/24", "10.0.102.0/24"] + + enable_nat_gateway = true + enable_vpn_gateway = false + + enable_dns_hostnames = true + enable_dns_support = true + + # Enable flow logs + enable_flow_log = true + create_flow_log_cloudwatch_log_group = true + create_flow_log_cloudwatch_iam_role = true + flow_log_max_aggregation_interval = 60 + + # VPC Flow Logs destination + flow_log_destination_type = "cloud-watch-log-group" + + public_subnet_tags = { + Type = "public" + } + + private_subnet_tags = { + Type = "private" + } + + tags = merge( + { + Project = "tf-via-pr-test" + Environment = "nonprod" + ManagedBy = "Terragrunt" + Component = "vpc" + }, + try(include.environment.locals.environment_tags, {}) + ) +} diff --git a/tests/terragrunt/root.hcl b/tests/terragrunt/root.hcl new file mode 100644 index 00000000..f2476c85 --- /dev/null +++ b/tests/terragrunt/root.hcl @@ -0,0 +1,48 @@ +# Root terragrunt.hcl - Common configuration for everything +# This file contains the base configuration that all child modules inherit + +# Include common configuration values +locals { + common = read_terragrunt_config(find_in_parent_folders("_envcommon/common.hcl")) + environment = read_terragrunt_config(find_in_parent_folders("environment.hcl")).locals + + # Backend configuration - consistent naming pattern + backend_config = { + bucket = "${local.common.locals.state_bucket}-${local.common.locals.bucket_suffixes[local.environment.environment]}" + key = "${path_relative_to_include()}/terraform.tfstate" + region = local.common.locals.aws_region + encrypt = true + use_lockfile = true # S3 native state locking (no DynamoDB required) + } +} + +# Backend configuration - consistent across all environments +remote_state { + backend = "s3" + config = local.backend_config + generate = { + path = "backend.tf" + if_exists = "overwrite_terragrunt" + } +} + +# Global variables that can be overridden by child modules +inputs = { + aws_region = get_env("TF_VAR_aws_region", "us-west-1") + environment = get_env("TF_VAR_environment", "dev") + project_name = get_env("TF_VAR_project_name", "tf-via-pr-test") + + # Assume role configuration for cross-account access + assume_role_arn = get_env("TF_VAR_assume_role_arn", "") + + # Common tags + common_tags = merge( + local.environment.environment_tags, + { + Environment = get_env("TF_VAR_environment", "dev") + Project = get_env("TF_VAR_project_name", "tf-via-pr-test") + ManagedBy = "Terragrunt" + Owner = "DevOps" + } + ) +} diff --git a/tests/terragrunt/verify-config.sh b/tests/terragrunt/verify-config.sh new file mode 100755 index 00000000..560e3ea8 --- /dev/null +++ b/tests/terragrunt/verify-config.sh @@ -0,0 +1,418 @@ +#!/bin/bash + +# TF-via-PR Terragrunt Configuration Verification Script +# This script verifies that the Terragrunt configuration is properly set up + +set -e + +echo "🔍 Verifying Terragrunt Configuration..." +echo "========================================" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to print status +print_status() { + if [ $1 -eq 0 ]; then + echo -e "${GREEN}✅ $2${NC}" + else + echo -e "${RED}❌ $2${NC}" + return 1 + fi +} + +# Function to print warning +print_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +# Function to print info +print_info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +# Function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to check file syntax +check_hcl_syntax() { + local file="$1" + local dir="$(dirname "$file")" + + # Skip validation for root-level files that don't have terragrunt.hcl + local skip_files=("root.hcl" "accounts.hcl" "_envcommon/common.hcl" "_envcommon/providers/aws.hcl" "live/project/nonprod/environment.hcl" "live/project/prod/environment.hcl") + for skip_file in "${skip_files[@]}"; do + if [ "$file" = "$skip_file" ]; then + # For these files, just check basic HCL syntax + if grep -q "locals\|inputs\|remote_state\|provider" "$file" 2>/dev/null; then + return 0 + else + return 1 + fi + fi + done + + # For terragrunt.hcl files, use terragrunt validation + if command_exists terragrunt && [ -f "$dir/terragrunt.hcl" ]; then + # Use terragrunt validate-inputs for actual terragrunt.hcl files + if terragrunt validate-inputs --terragrunt-working-dir "$dir" &>/dev/null; then + return 0 + else + # If validation fails, it might be due to dependencies, so check if it's a terragrunt.hcl file + if [ "$(basename "$file")" = "terragrunt.hcl" ]; then + # For terragrunt.hcl files, check basic syntax as fallback + if grep -q "locals\|inputs\|remote_state\|terraform\|include\|dependency" "$file" 2>/dev/null; then + return 0 + else + return 1 + fi + else + return 1 + fi + fi + else + # Fallback: basic syntax check + if grep -q "locals\|inputs\|remote_state\|terraform\|include" "$file" 2>/dev/null; then + return 0 + else + return 1 + fi + fi +} + +# Check if terragrunt is installed +echo "Checking Terragrunt installation..." +if command_exists terragrunt; then + TERRAGRUNT_VERSION=$(terragrunt --version | head -n1) + print_status 0 "Terragrunt is installed: $TERRAGRUNT_VERSION" + + # Check minimum version (0.48.0 as per GitHub Actions) + TERRAGRUNT_VERSION_NUM=$(terragrunt --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1) + if [ "$(printf '%s\n' "0.48.0" "$TERRAGRUNT_VERSION_NUM" | sort -V | head -n1)" = "0.48.0" ]; then + print_status 0 "Terragrunt version meets minimum requirement (>=0.48.0)" + else + print_warning "Terragrunt version $TERRAGRUNT_VERSION_NUM may be outdated (minimum: 0.48.0)" + fi +else + print_status 1 "Terragrunt is not installed" + print_info "Install from: https://terragrunt.gruntwork.io/docs/getting-started/install/" + exit 1 +fi + +# Check if terraform is installed +echo "Checking Terraform installation..." +if command_exists terraform; then + TERRAFORM_VERSION=$(terraform --version | head -n1) + print_status 0 "Terraform is installed: $TERRAFORM_VERSION" + + # Check minimum version (1.5.0 as per GitHub Actions) + TERRAFORM_VERSION_NUM=$(terraform --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1) + if [ "$(printf '%s\n' "1.5.0" "$TERRAFORM_VERSION_NUM" | sort -V | head -n1)" = "1.5.0" ]; then + print_status 0 "Terraform version meets minimum requirement (>=1.5.0)" + else + print_warning "Terraform version $TERRAFORM_VERSION_NUM may be outdated (minimum: 1.5.0)" + fi +else + print_status 1 "Terraform is not installed" + print_info "Install from: https://developer.hashicorp.com/terraform/downloads" + exit 1 +fi + +# Check if AWS CLI is installed +echo "Checking AWS CLI installation..." +if command_exists aws; then + AWS_VERSION=$(aws --version) + print_status 0 "AWS CLI is installed: $AWS_VERSION" +else + print_status 1 "AWS CLI is not installed" + print_info "Install from: https://aws.amazon.com/cli/" + exit 1 +fi + +# Check if jq is installed (needed for GitHub Actions) +echo "Checking jq installation..." +if command_exists jq; then + JQ_VERSION=$(jq --version) + print_status 0 "jq is installed: $JQ_VERSION" +else + print_warning "jq is not installed (required for GitHub Actions workflow)" + print_info "Install from: https://stedolan.github.io/jq/" +fi + +# Check AWS credentials +echo "Checking AWS credentials..." +if aws sts get-caller-identity &> /dev/null; then + AWS_ACCOUNT=$(aws sts get-caller-identity --query Account --output text) + AWS_REGION=$(aws configure get region) + print_status 0 "AWS credentials are configured (Account: $AWS_ACCOUNT, Region: $AWS_REGION)" +else + print_warning "AWS credentials are not configured or invalid" + print_info "Configure AWS credentials using 'aws configure' or environment variables" + print_info "For testing purposes, you can continue without AWS credentials" +fi + +# Check required files exist +echo "Checking required configuration files..." + +REQUIRED_FILES=( + "root.hcl" + "accounts.hcl" + "_envcommon/common.hcl" + "_envcommon/providers/aws.hcl" + "live/project/nonprod/environment.hcl" + "live/project/prod/environment.hcl" +) + +for file in "${REQUIRED_FILES[@]}"; do + if [ -f "$file" ]; then + print_status 0 "Found: $file" + # Check HCL syntax + if check_hcl_syntax "$file"; then + print_status 0 "Syntax valid: $file" + else + print_warning "Syntax check failed for: $file" + fi + else + print_status 1 "Missing: $file" + exit 1 + fi +done + +# Check component directories +echo "Checking component directories..." + +COMPONENT_DIRS=( + "live/project/nonprod/vpc" + "live/project/nonprod/ec2" + "live/project/nonprod/s3" + "live/project/prod/vpc" + "live/project/prod/ec2" + "live/project/prod/s3" +) + +for dir in "${COMPONENT_DIRS[@]}"; do + if [ -d "$dir" ]; then + if [ -f "$dir/terragrunt.hcl" ]; then + print_status 0 "Found: $dir/terragrunt.hcl" + # Check HCL syntax + if check_hcl_syntax "$dir/terragrunt.hcl"; then + print_status 0 "Syntax valid: $dir/terragrunt.hcl" + else + print_warning "Syntax check failed for: $dir/terragrunt.hcl" + fi + else + print_warning "Missing terragrunt.hcl in directory: $dir (may be placeholder)" + fi + else + print_warning "Missing directory: $dir (may be placeholder)" + fi +done + +# Validate Terragrunt configuration syntax +echo "Validating Terragrunt configuration syntax..." + +# Test root configuration by checking if it can be included by child modules +echo "Testing root configuration inclusion..." +if [ -f "live/project/nonprod/vpc/terragrunt.hcl" ]; then + if terragrunt validate-inputs --terragrunt-working-dir live/project/nonprod/vpc &> /dev/null; then + print_status 0 "Root configuration can be included by child modules" + else + print_warning "Root configuration may have issues when included by child modules" + print_info "Run 'terragrunt validate-inputs --terragrunt-working-dir live/project/nonprod/vpc' for detailed error information" + fi +else + print_warning "Cannot test root configuration - no child modules found" +fi + +# Test sample components (only if they exist) +echo "Testing component configurations..." + +# Test nonprod VPC if it exists +if [ -f "live/project/nonprod/vpc/terragrunt.hcl" ]; then + if terragrunt validate-inputs --terragrunt-working-dir live/project/nonprod/vpc &> /dev/null; then + print_status 0 "Nonprod VPC configuration syntax is valid" + else + print_warning "Nonprod VPC configuration syntax is invalid" + fi +fi + +# Test nonprod EC2 if it exists +if [ -f "live/project/nonprod/ec2/terragrunt.hcl" ]; then + if terragrunt validate-inputs --terragrunt-working-dir live/project/nonprod/ec2 &> /dev/null; then + print_status 0 "Nonprod EC2 configuration syntax is valid" + else + print_warning "Nonprod EC2 configuration syntax validation failed" + print_info "This may be due to dependency issues. Check if mock_outputs_allowed_terraform_commands includes 'validate-inputs'" + print_info "Run 'terragrunt validate-inputs --terragrunt-working-dir live/project/nonprod/ec2' for detailed error information" + fi +fi + +# Test nonprod S3 if it exists +if [ -f "live/project/nonprod/s3/terragrunt.hcl" ]; then + if terragrunt validate-inputs --terragrunt-working-dir live/project/nonprod/s3 &> /dev/null; then + print_status 0 "Nonprod S3 configuration syntax is valid" + else + print_warning "Nonprod S3 configuration syntax is invalid" + fi +fi + +# Test prod S3 if it exists +if [ -f "live/project/prod/s3/terragrunt.hcl" ]; then + if terragrunt validate-inputs --terragrunt-working-dir live/project/prod/s3 &> /dev/null; then + print_status 0 "Prod S3 configuration syntax is valid" + else + print_warning "Prod S3 configuration syntax is invalid" + fi +fi + +# Check for Terragrunt-specific configuration issues +echo "Checking Terragrunt-specific configuration..." + +# Check if root.hcl includes common configuration +if grep -q "read_terragrunt_config.*common.hcl" root.hcl; then + print_status 0 "Root configuration includes common settings" +else + print_warning "Root configuration may not include common settings" +fi + +# Check if accounts.hcl has proper structure +if grep -q "accounts.*=" accounts.hcl && grep -q "nonprod\|prod" accounts.hcl; then + print_status 0 "Accounts configuration has proper structure" +else + print_warning "Accounts configuration may be incomplete" +fi + +# Check if AWS provider configuration exists +if [ -f "_envcommon/providers/aws.hcl" ]; then + if grep -q "provider.*aws" _envcommon/providers/aws.hcl; then + print_status 0 "AWS provider configuration is present" + else + print_warning "AWS provider configuration may be incomplete" + fi +fi + +# Check for common issues +echo "Checking for common configuration issues..." + +# Check if assume_role_arn is set to placeholder +if grep -q "xxxxxxx" accounts.hcl; then + print_warning "assume_role_arn is set to placeholder value 'xxxxxxx' in accounts.hcl" + print_info "Update assume_role_arn values in accounts.hcl with actual ARNs" +fi + +# Check if bucket names are consistent +echo "Checking bucket naming consistency..." +BUCKET_NAMES=$(grep -r "state_bucket\|lock_table" _envcommon/ | wc -l) +if [ $BUCKET_NAMES -gt 0 ]; then + print_status 0 "Backend bucket names are configured" +else + print_warning "No backend bucket names found in configuration" +fi + +# Check AWS account IDs +echo "Checking AWS account configuration..." +if grep -q "155524221786\|813676077823" accounts.hcl; then + print_status 0 "AWS account IDs are configured" +else + print_warning "AWS account IDs may not be properly configured" +fi + +# Check for GitHub Actions compatibility +echo "Checking GitHub Actions compatibility..." + +# Check if GitHub Actions workflow exists +if [ -f "../../.github/workflows/terragrunt-live-all.yml" ]; then + print_status 0 "GitHub Actions workflow exists" + + # Check workflow permissions + if grep -q "id-token: write" "../../.github/workflows/terragrunt-live-all.yml"; then + print_status 0 "GitHub Actions workflow has OIDC permissions" + else + print_warning "GitHub Actions workflow may lack OIDC permissions" + fi + + # Check for required secrets reference + if grep -q "AWS_ROLE_TO_ASSUME" "../../.github/workflows/terragrunt-live-all.yml"; then + print_status 0 "GitHub Actions workflow references AWS_ROLE_TO_ASSUME secret" + else + print_warning "GitHub Actions workflow may not reference AWS_ROLE_TO_ASSUME secret" + fi +else + print_warning "GitHub Actions workflow not found" +fi + +# Check pre-commit configuration +if [ -f ".pre-commit-config.yaml" ]; then + print_status 0 "Pre-commit configuration exists" + + # Check for Terraform hooks + if grep -q "terraform_fmt\|terraform_validate" .pre-commit-config.yaml; then + print_status 0 "Pre-commit has Terraform hooks configured" + else + print_warning "Pre-commit may lack Terraform hooks" + fi +else + print_warning "Pre-commit configuration not found" +fi + +# Check for dependency management +echo "Checking dependency configuration..." + +# Check if EC2 depends on VPC (if both exist) +if [ -f "live/project/nonprod/ec2/terragrunt.hcl" ] && [ -f "live/project/nonprod/vpc/terragrunt.hcl" ]; then + if grep -q "dependency.*vpc\|depends_on.*vpc" live/project/nonprod/ec2/terragrunt.hcl; then + print_status 0 "EC2 component has VPC dependency configured" + else + print_warning "EC2 component may lack VPC dependency configuration" + fi +fi + +# Check module sources +echo "Checking Terraform module sources..." +MODULE_SOURCES=$(grep -r "terraform-aws-modules" live/ | wc -l) +if [ $MODULE_SOURCES -gt 0 ]; then + print_status 0 "Using official terraform-aws-modules" +else + print_warning "No terraform-aws-modules found in configuration" +fi + +echo "" +echo "🎉 Configuration verification completed!" +echo "" +echo "📋 Summary:" +echo "===========" +echo "✅ Prerequisites checked (Terragrunt, Terraform, AWS CLI)" +echo "✅ Configuration files validated" +echo "✅ Syntax checks performed" +echo "✅ Terragrunt-specific settings verified" +echo "✅ CI/CD compatibility assessed" +echo "" +echo "🚀 Next steps:" +echo "==============" +echo "1. Update assume_role_arn values in accounts.hcl if needed" +echo "2. Configure AWS_ROLE_TO_ASSUME secret in GitHub repository" +echo "3. Test individual components:" +echo " cd live/project/nonprod/vpc && terragrunt plan" +echo " cd live/project/nonprod/s3 && terragrunt plan" +echo "4. Test environment-wide operations:" +echo " terragrunt run-all plan --terragrunt-working-dir live/project/nonprod" +echo "5. Run pre-commit hooks: pre-commit run --all-files" +echo "" +echo "🔧 Troubleshooting:" +echo "===================" +echo "• For syntax errors: terragrunt validate-inputs" +echo "• For dependency issues: terragrunt graph-dependencies" +echo "• For AWS permissions: aws sts get-caller-identity" +echo "• For state issues: Check DynamoDB locks and S3 bucket access" +echo "" +echo "📚 Documentation:" +echo "================" +echo "• Terragrunt: https://terragrunt.gruntwork.io/docs/" +echo "• GitHub Actions: See .github/workflows/terragrunt-live-all.yml" +echo "• Project README: tests/terragrunt/README.md"