diff --git a/.github/workflows/terraform-root-apply.yaml b/.github/workflows/terraform-root-apply.yaml index 5acc481..4eb28b6 100644 --- a/.github/workflows/terraform-root-apply.yaml +++ b/.github/workflows/terraform-root-apply.yaml @@ -3,11 +3,10 @@ name: 'Terraform Apply' on: workflow_call: inputs: - workflow: - description: 'Which workflow step to run.' - required: false + root: + description: 'The root module directory to apply.' + required: true type: string - default: '' runs-on: description: 'Agent selection string.' required: false @@ -21,6 +20,10 @@ on: description: 'The AWS region to assume IAM in for Terraform operations.' required: true type: string + environment: + description: 'GitHub environment for approval gates (must have required reviewers configured).' + required: true + type: string tailscale: description: 'Should the job connect to Tailscale.' required: false @@ -48,36 +51,115 @@ permissions: contents: read jobs: - terraform-apply: - name: 'Terraform Apply' + plan: + name: 'Plan: ${{ inputs.root }}' defaults: run: shell: bash - working-directory: './${{ inputs.workflow }}' + working-directory: './${{ inputs.root }}' runs-on: ${{ inputs.runs-on }} env: TF_IN_AUTOMATION: true steps: - - name: Sanity check + - name: Sanity Check if: github.ref != 'refs/heads/main' - run: echo 'Not running against 'main' branch, exiting.' && exit 1 + run: echo "Not running against 'main' branch, exiting." && exit 1 + + - name: Checkout + uses: actions/checkout@v6 + + - name: Read Terraform Version + run: echo "TF_VERSION=$(cat .terraform-version)" >> $GITHUB_ENV + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v5 + with: + role-to-assume: ${{ inputs.aws-assume-role-arn }} + aws-region: ${{ inputs.aws-assume-role-region }} + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3.1.2 + with: + terraform_version: ${{ env.TF_VERSION }} + cli_config_credentials_token: ${{ secrets.terraform-registry-token }} + terraform_wrapper: false + + - name: Initialize Terraform + run: terraform init + + - name: Tailscale + if: inputs.tailscale == true + uses: tailscale/github-action@v4 + with: + oauth-client-id: ${{ secrets.tailscale-client-id }} + oauth-secret: ${{ secrets.tailscale-secret }} + tags: ${{ secrets.tailscale-tags }} + version: 1.82.5 + + - name: Terraform Plan + run: | + ${{ secrets.terraform-env-vars }} terraform plan -input=false -out=tfplan 2>&1 | tee plan-output.txt + + - name: Generate Plan Text + run: terraform show tfplan -no-color > plan-readable.txt + + - name: Show Plan in Workflow Summary + run: | + echo "## Terraform Plan: \`${{ inputs.root }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```hcl' >> $GITHUB_STEP_SUMMARY + cat plan-readable.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + + - name: Upload Plan Artifact + uses: actions/upload-artifact@v4 + with: + name: tfplan-${{ inputs.root }} + path: | + ./${{ inputs.root }}/tfplan + ./${{ inputs.root }}/plan-readable.txt + retention-days: 1 + + apply: + name: 'Apply: ${{ inputs.root }}' + needs: plan + runs-on: ${{ inputs.runs-on }} + environment: ${{ inputs.environment }} + defaults: + run: + shell: bash + working-directory: './${{ inputs.root }}' + env: + TF_IN_AUTOMATION: true + steps: - name: Checkout uses: actions/checkout@v6 + - name: Read Terraform Version run: echo "TF_VERSION=$(cat .terraform-version)" >> $GITHUB_ENV + - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v5 with: role-to-assume: ${{ inputs.aws-assume-role-arn }} aws-region: ${{ inputs.aws-assume-role-region }} + - name: Setup Terraform uses: hashicorp/setup-terraform@v3.1.2 with: terraform_version: ${{ env.TF_VERSION }} cli_config_credentials_token: ${{ secrets.terraform-registry-token }} terraform_wrapper: false + - name: Initialize Terraform run: terraform init + + - name: Download Plan Artifact + uses: actions/download-artifact@v4 + with: + name: tfplan-${{ inputs.root }} + path: ./${{ inputs.root }} + - name: Tailscale if: inputs.tailscale == true uses: tailscale/github-action@v4 @@ -86,5 +168,6 @@ jobs: oauth-secret: ${{ secrets.tailscale-secret }} tags: ${{ secrets.tailscale-tags }} version: 1.82.5 + - name: Terraform Apply - run: ${{ secrets.terraform-env-vars }} terraform apply -auto-approve -input=false + run: ${{ secrets.terraform-env-vars }} terraform apply -input=false tfplan diff --git a/.github/workflows/terraform-root-ci.yaml b/.github/workflows/terraform-root-ci.yaml new file mode 100644 index 0000000..b2dc38c --- /dev/null +++ b/.github/workflows/terraform-root-ci.yaml @@ -0,0 +1,301 @@ +name: 'Terraform CI' + +on: + workflow_call: + inputs: + roots: + description: 'JSON array of all root module directories to monitor (e.g. ["feature-a", "networking", "compute"]).' + required: true + type: string + runs-on: + description: 'Agent selection string.' + required: false + default: 'ubuntu-latest' + type: string + aws-assume-role-arn: + description: 'The AWS Role ARN to assume for Terraform operations.' + required: true + type: string + aws-assume-role-region: + description: 'The AWS region to assume IAM in for Terraform operations.' + required: true + type: string + tailscale: + description: 'Should the job connect to Tailscale.' + required: false + type: boolean + default: false + ai-summary: + description: 'Generate an AI summary of the plan using GitHub Models.' + required: false + type: boolean + default: false + secrets: + terraform-registry-token: + description: 'Terraform registry token to authorize Terraform operations.' + required: false + terraform-env-vars: + description: 'Environment variables to inject into Terraform operations.' + required: false + tailscale-client-id: + description: 'Your Tailscale OAuth Client ID.' + required: false + tailscale-secret: + description: 'Your Tailscale OAuth Client Secret.' + required: false + tailscale-tags: + description: 'Comma separated list of Tags to be applied to nodes. The OAuth client must have permission to apply these tags.' + required: false + +permissions: + id-token: write + contents: read + pull-requests: write + models: read + +jobs: + detect-changes: + name: 'Detect Changed Roots' + runs-on: ${{ inputs.runs-on }} + outputs: + changed: ${{ steps.filter.outputs.changed }} + has_changes: ${{ steps.filter.outputs.has_changes }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect Changed Roots + id: filter + run: | + ROOTS='${{ inputs.roots }}' + CHANGED=() + + # Determine diff base + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + else + BASE="${{ github.event.before }}" + HEAD="${{ github.sha }}" + fi + + # Get list of changed files + CHANGED_FILES=$(git diff --name-only "$BASE" "$HEAD" 2>/dev/null || git diff --name-only HEAD~1 HEAD) + + # Check each root for changes + for root in $(echo "$ROOTS" | jq -r '.[]'); do + if echo "$CHANGED_FILES" | grep -q "^${root}/"; then + CHANGED+=("\"${root}\"") + fi + done + + # Build JSON output + if [ ${#CHANGED[@]} -eq 0 ]; then + echo "changed=[]" >> $GITHUB_OUTPUT + echo "has_changes=false" >> $GITHUB_OUTPUT + else + CHANGED_JSON=$(printf '%s\n' "${CHANGED[@]}" | jq -s -c '.') + echo "changed=${CHANGED_JSON}" >> $GITHUB_OUTPUT + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + echo "### Changed Roots" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ ${#CHANGED[@]} -eq 0 ]; then + echo "No Terraform roots changed." >> $GITHUB_STEP_SUMMARY + else + for root in $(echo "$ROOTS" | jq -r '.[]'); do + if echo "$CHANGED_FILES" | grep -q "^${root}/"; then + echo "- ✅ \`${root}\`" >> $GITHUB_STEP_SUMMARY + else + echo "- ⏭️ \`${root}\` (no changes)" >> $GITHUB_STEP_SUMMARY + fi + done + fi + + lint: + name: 'Lint: ${{ matrix.root }}' + needs: detect-changes + if: needs.detect-changes.outputs.has_changes == 'true' + runs-on: ${{ inputs.runs-on }} + strategy: + fail-fast: false + matrix: + root: ${{ fromJson(needs.detect-changes.outputs.changed) }} + defaults: + run: + shell: bash + working-directory: './${{ matrix.root }}' + env: + TF_IN_AUTOMATION: true + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Read Terraform Version + run: echo "TF_VERSION=$(cat .terraform-version)" >> $GITHUB_ENV + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v5 + with: + role-to-assume: ${{ inputs.aws-assume-role-arn }} + aws-region: ${{ inputs.aws-assume-role-region }} + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3.1.2 + with: + terraform_version: ${{ env.TF_VERSION }} + cli_config_credentials_token: ${{ secrets.terraform-registry-token }} + + - name: Check Terraform Format + run: terraform fmt -check + + - name: Initialize and Validate + run: terraform init && terraform validate + + - name: Setup TFLint + uses: terraform-linters/setup-tflint@v6 + + - name: Check Terraform Lint + run: tflint --init && tflint -f compact + env: + GITHUB_TOKEN: ${{ github.token }} + + plan: + name: 'Plan: ${{ matrix.root }}' + needs: [detect-changes, lint] + if: needs.detect-changes.outputs.has_changes == 'true' + runs-on: ${{ inputs.runs-on }} + strategy: + fail-fast: false + matrix: + root: ${{ fromJson(needs.detect-changes.outputs.changed) }} + defaults: + run: + shell: bash + working-directory: './${{ matrix.root }}' + env: + TF_IN_AUTOMATION: true + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Read Terraform Version + run: echo "TF_VERSION=$(cat .terraform-version)" >> $GITHUB_ENV + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v5 + with: + role-to-assume: ${{ inputs.aws-assume-role-arn }} + aws-region: ${{ inputs.aws-assume-role-region }} + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3.1.2 + with: + terraform_version: ${{ env.TF_VERSION }} + cli_config_credentials_token: ${{ secrets.terraform-registry-token }} + terraform_wrapper: false + + - name: Initialize Terraform + run: terraform init + + - name: Tailscale + if: inputs.tailscale == true + uses: tailscale/github-action@v4 + with: + oauth-client-id: ${{ secrets.tailscale-client-id }} + oauth-secret: ${{ secrets.tailscale-secret }} + tags: ${{ secrets.tailscale-tags }} + version: 1.82.5 + + - name: Terraform Plan + id: plan + run: | + ${{ secrets.terraform-env-vars }} terraform plan -input=false -out=tfplan 2>&1 | tee plan-output.txt + echo "exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT + + - name: Generate Plan Text + if: always() + run: terraform show tfplan -no-color > plan-readable.txt 2>/dev/null || true + + - name: Upload Plan Artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: tfplan-${{ matrix.root }} + path: | + ./${{ matrix.root }}/tfplan + ./${{ matrix.root }}/plan-readable.txt + ./${{ matrix.root }}/plan-output.txt + retention-days: 7 + + - name: AI Summary + if: inputs.ai-summary == true && github.event_name == 'pull_request' + id: ai-summary + uses: actions/ai-inference@v1 + with: + prompt: | + You are a Terraform expert reviewing infrastructure changes. Summarize the following Terraform plan in a concise, human-readable way. Focus on: + - What resources are being created, changed, or destroyed + - Any potentially dangerous changes (destroys, replacements, security group changes) + - A risk assessment (low/medium/high) + + Keep the summary brief and actionable. Use markdown formatting. + + Plan output: + ``` + $(cat plan-readable.txt | head -500) + ``` + model: openai/gpt-4o-mini + + - name: Post PR Comment + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{ github.token }} + script: | + const fs = require('fs'); + const root = '${{ matrix.root }}'; + const planOutput = fs.readFileSync(`${root}/plan-readable.txt`, 'utf8'); + const aiSummary = `${{ steps.ai-summary.outputs.response }}` || ''; + + const maxLength = 60000; + const truncated = planOutput.length > maxLength + ? planOutput.substring(0, maxLength) + '\n\n... (truncated, see artifact for full plan)' + : planOutput; + + let body = `## Terraform Plan: \`${root}\`\n\n`; + + if (aiSummary) { + body += `### 🤖 AI Summary\n\n${aiSummary}\n\n---\n\n`; + } + + body += `
\nPlan output (click to expand)\n\n\`\`\`hcl\n${truncated}\n\`\`\`\n\n
\n\n`; + body += `📦 Full plan available as [workflow artifact](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const marker = `## Terraform Plan: \`${root}\``; + const existing = comments.find(c => c.body.startsWith(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body, + }); + } diff --git a/.github/workflows/terraform-root-continuous-integration.yaml b/.github/workflows/terraform-root-continuous-integration.yaml deleted file mode 100644 index e68320a..0000000 --- a/.github/workflows/terraform-root-continuous-integration.yaml +++ /dev/null @@ -1,65 +0,0 @@ -name: 'Continuous Integration' - -on: - workflow_call: - inputs: - runs-on: - description: 'Agent selection string.' - required: false - default: 'ubuntu-latest' - type: string - aws-assume-role-arn: - description: 'The AWS Role ARN to assume for Terraform operations.' - required: true - type: string - aws-assume-role-region: - description: 'The AWS region to assume IAM in for Terraform operations.' - required: true - type: string - secrets: - terraform-registry-token: - description: 'Terraform registry token to authorize Terraform operations.' - required: false - -permissions: - id-token: write - contents: read - -jobs: - continuous-integration: - name: 'Continuous Integration' - runs-on: ${{ inputs.runs-on }} - env: - TF_IN_AUTOMATION: true - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Read Terraform Version - run: echo "TF_VERSION=$(cat .terraform-version)" >> $GITHUB_ENV - - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v5 - with: - role-to-assume: ${{ inputs.aws-assume-role-arn }} - aws-region: ${{ inputs.aws-assume-role-region }} - - name: Setup Terraform - uses: hashicorp/setup-terraform@v3.1.2 - with: - terraform_version: ${{ env.TF_VERSION }} - cli_config_credentials_token: ${{ secrets.terraform-registry-token }} - - name: Check Terraform Format - run: terraform fmt -check - - name: Check Terraform Syntax - run: terraform init && terraform validate - - name: Setup TFLint - uses: terraform-linters/setup-tflint@v6 - - name: Check Terraform Lint - run: tflint --init && tflint -f compact - env: - GITHUB_TOKEN: ${{ github.token }} - - name: Check Terraform Security (TFsec) - uses: aquasecurity/tfsec-action@v1.0.3 - - name: Check Terraform Security (Checkov) - uses: bridgecrewio/checkov-action@v12 - with: - directory: . - framework: terraform diff --git a/.github/workflows/terraform-root-destroy.yaml b/.github/workflows/terraform-root-destroy.yaml index 89d754d..28f1769 100644 --- a/.github/workflows/terraform-root-destroy.yaml +++ b/.github/workflows/terraform-root-destroy.yaml @@ -3,6 +3,10 @@ name: 'Terraform Destroy' on: workflow_call: inputs: + root: + description: 'The root module directory to destroy.' + required: true + type: string runs-on: description: 'Agent selection string.' required: false @@ -21,6 +25,10 @@ on: description: 'The AWS region to assume IAM in for Terraform operations.' required: true type: string + environment: + description: 'GitHub environment for approval gates.' + required: true + type: string tailscale: description: 'Should the job connect to Tailscale.' required: false @@ -49,31 +57,42 @@ permissions: jobs: terraform-destroy: - name: 'Terraform Destroy' + name: 'Destroy: ${{ inputs.root }}' runs-on: ${{ inputs.runs-on }} + environment: ${{ inputs.environment }} + defaults: + run: + shell: bash + working-directory: './${{ inputs.root }}' env: TF_IN_AUTOMATION: true steps: - - name: Sanity check + - name: Sanity Check if: github.ref != 'refs/heads/main' || inputs.confirm != 'true' - run: echo 'Not running against 'main' branch or the action has not been confirmed, exiting.' && exit 1 + run: echo "Not running against 'main' branch or the action has not been confirmed, exiting." && exit 1 + - name: Checkout uses: actions/checkout@v6 + - name: Read Terraform Version run: echo "TF_VERSION=$(cat .terraform-version)" >> $GITHUB_ENV + - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v5 with: role-to-assume: ${{ inputs.aws-assume-role-arn }} aws-region: ${{ inputs.aws-assume-role-region }} + - name: Setup Terraform uses: hashicorp/setup-terraform@v3.1.2 with: terraform_version: ${{ env.TF_VERSION }} cli_config_credentials_token: ${{ secrets.terraform-registry-token }} terraform_wrapper: false + - name: Initialize Terraform run: terraform init + - name: Tailscale if: inputs.tailscale == true uses: tailscale/github-action@v4 @@ -81,5 +100,7 @@ jobs: oauth-client-id: ${{ secrets.tailscale-client-id }} oauth-secret: ${{ secrets.tailscale-secret }} tags: ${{ secrets.tailscale-tags }} + version: 1.82.5 + - name: Terraform Destroy run: ${{ secrets.terraform-env-vars }} terraform destroy -auto-approve -input=false diff --git a/.github/workflows/terraform-root-plan.yaml b/.github/workflows/terraform-root-plan.yaml index f45e95e..eee1505 100644 --- a/.github/workflows/terraform-root-plan.yaml +++ b/.github/workflows/terraform-root-plan.yaml @@ -3,11 +3,10 @@ name: 'Terraform Plan' on: workflow_call: inputs: - workflow: - description: 'Which workflow step to run.' - required: false + root: + description: 'The root module directory to plan.' + required: true type: string - default: '' runs-on: description: 'Agent selection string.' required: false @@ -26,6 +25,11 @@ on: required: false type: boolean default: false + ai-summary: + description: 'Generate an AI summary of the plan using GitHub Models.' + required: false + type: boolean + default: false secrets: terraform-registry-token: description: 'Terraform registry token to authorize Terraform operations.' @@ -46,35 +50,42 @@ on: permissions: id-token: write contents: read + pull-requests: write + models: read jobs: terraform-plan: - name: 'Terraform Plan' + name: 'Plan: ${{ inputs.root }}' defaults: run: shell: bash - working-directory: './${{ inputs.workflow }}' + working-directory: './${{ inputs.root }}' runs-on: ${{ inputs.runs-on }} env: TF_IN_AUTOMATION: true steps: - name: Checkout uses: actions/checkout@v6 + - name: Read Terraform Version run: echo "TF_VERSION=$(cat .terraform-version)" >> $GITHUB_ENV + - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v5 with: role-to-assume: ${{ inputs.aws-assume-role-arn }} aws-region: ${{ inputs.aws-assume-role-region }} + - name: Setup Terraform uses: hashicorp/setup-terraform@v3.1.2 with: terraform_version: ${{ env.TF_VERSION }} cli_config_credentials_token: ${{ secrets.terraform-registry-token }} terraform_wrapper: false + - name: Initialize Terraform run: terraform init + - name: Tailscale if: inputs.tailscale == true uses: tailscale/github-action@v4 @@ -83,5 +94,95 @@ jobs: oauth-secret: ${{ secrets.tailscale-secret }} tags: ${{ secrets.tailscale-tags }} version: 1.82.5 + - name: Terraform Plan - run: ${{ secrets.terraform-env-vars }} terraform plan -input=false + id: plan + run: | + ${{ secrets.terraform-env-vars }} terraform plan -input=false -out=tfplan 2>&1 | tee plan-output.txt + echo "exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT + + - name: Generate Plan Text + if: always() + run: terraform show tfplan -no-color > plan-readable.txt 2>/dev/null || true + + - name: Upload Plan Artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: tfplan-${{ inputs.root }} + path: | + ./${{ inputs.root }}/tfplan + ./${{ inputs.root }}/plan-readable.txt + ./${{ inputs.root }}/plan-output.txt + retention-days: 7 + + - name: AI Summary + if: inputs.ai-summary == true && github.event_name == 'pull_request' + id: ai-summary + uses: actions/ai-inference@v1 + with: + prompt: | + You are a Terraform expert reviewing infrastructure changes. Summarize the following Terraform plan in a concise, human-readable way. Focus on: + - What resources are being created, changed, or destroyed + - Any potentially dangerous changes (destroys, replacements, security group changes) + - A risk assessment (low/medium/high) + + Keep the summary brief and actionable. Use markdown formatting. + + Plan output: + ``` + $(cat plan-readable.txt | head -500) + ``` + model: openai/gpt-4o-mini + + - name: Post PR Comment + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{ github.token }} + script: | + const fs = require('fs'); + const root = '${{ inputs.root }}'; + const planOutput = fs.readFileSync(`${root}/plan-readable.txt`, 'utf8'); + const aiSummary = `${{ steps.ai-summary.outputs.response }}` || ''; + + // Truncate plan if too long for a comment + const maxLength = 60000; + const truncated = planOutput.length > maxLength + ? planOutput.substring(0, maxLength) + '\n\n... (truncated, see artifact for full plan)' + : planOutput; + + let body = `## Terraform Plan: \`${root}\`\n\n`; + + if (aiSummary) { + body += `### 🤖 AI Summary\n\n${aiSummary}\n\n---\n\n`; + } + + body += `
\nPlan output (click to expand)\n\n\`\`\`hcl\n${truncated}\n\`\`\`\n\n
\n\n`; + body += `📦 Full plan available as [workflow artifact](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})`; + + // Find existing comment for this root + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const marker = `## Terraform Plan: \`${root}\``; + const existing = comments.find(c => c.body.startsWith(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body, + }); + }