Last active
January 31, 2023 08:16
-
-
Save jim80net/d6a9d291c2f860e14c825a92063aea13 to your computer and use it in GitHub Desktop.
Github Action to add a terraform plan to pull requests.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
name: Add Terraform Plan to Pull Requests | |
on: | |
pull_request: | |
env: | |
# See page below about disabling Hashicorp Upgrade and Security Checks | |
# https://www.terraform.io/docs/commands/index.html#upgrade-and-security-bulletin-checks | |
CHECKPOINT_DISABLE: true | |
jobs: | |
terraform-plan: | |
strategy: | |
fail-fast: false | |
max-parallel: 64 | |
matrix: | |
# To refresh, use ruby -e 'puts Dir.glob("**/main.tf").reject {|x| x.include?("module")}.sort.map {|s| %Q[ "#{s.gsub("/main.tf", "\",")}]};' | |
directory: | |
[ | |
"path/to/root/module", | |
"path/to/other/module" | |
] | |
runs-on: ubuntu-latest | |
defaults: | |
run: | |
working-directory: ${{ matrix.directory }} | |
# These permissions are needed to interact with GitHub's OIDC Token endpoint. | |
permissions: | |
actions: read # read artifacts | |
id-token: write # get oidc token | |
contents: read # read artifacts | |
pull-requests: write # for listing and writing comments | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v2 | |
- name: Set outputs | |
id: vars | |
uses: actions/github-script@v4 | |
env: | |
WORKING_DIRECTORY: ${{ matrix.directory }} | |
with: | |
script: | | |
const { WORKING_DIRECTORY } = process.env | |
core.setOutput('directory_slug', WORKING_DIRECTORY.replace(/[^a-zA-Z0-9]/g, '-')) | |
- name: Configure AWS | |
uses: aws-actions/configure-aws-credentials@v1 | |
with: | |
aws-region: us-east-1 | |
role-to-assume: arn:aws:iam::12345:role/my-federated-gha-role | |
- name: Install tfenv | |
working-directory: "${{ github.workspace }}" | |
run: | | |
mkdir -p $HOME/.tfenv | |
cd $HOME/.tfenv | |
# download | |
wget https://github.com/tfutils/tfenv/archive/v2.2.2.tar.gz | |
# verify that it's legit | |
echo "ac7f74d8a0151e36a539ceae1460b320ec7b98b360dbd7799dc7cdbdf8c06ded v2.2.2.tar.gz" | sha256sum --check --strict | |
# install | |
tar -zxvf v2.2.2.tar.gz | |
echo "$(pwd)/tfenv-2.2.2/bin" >> $GITHUB_PATH | |
- name: set terraform version | |
run: tfenv use || tfenv install && tfenv use | |
- name: terraform init | |
run: terraform init --upgrade | |
- name: terraform validate | |
run: terraform validate | |
- name: terraform plan | |
run: | | |
terraform plan -no-color -out tf.plan -lock=false| \ | |
egrep -v 'Refreshing state|no actions|already matches the changes detected above|refresh-only|actions to undo or respond|Refreshing|refreshed|ignore_changes|did not detect any differences|As a result, no|actions need to be performed|persisted|Unless you have made equivalent changes' | \ | |
tee -a plan_output.txt | |
- name: Upload plan to S3 | |
run: | | |
aws s3 cp tf.plan s3://my-gha-support-bucket/github-actions/plans/${{ github.sha }}-${{ matrix.directory }}/tf.plan | |
- name: Are my changes from git or drift | |
id: drift | |
if: always() | |
run: | | |
git fetch --depth 1 origin master | |
tf_change_count=$(git diff origin/master --dirstat=files,0 | grep -c ${{ matrix.directory }} || :) | |
if [[ $tf_change_count -lt 1 ]]; then | |
echo "::set-output name=drift::true" | |
else | |
echo "::set-output name=drift::false" | |
fi | |
- name: report on pr | |
uses: actions/github-script@v5 | |
if: always() | |
with: | |
script: | | |
const fs = require('fs'); | |
const myDirectory = '${{ matrix.directory }}'; | |
const isDrift = ${{ steps.drift.outputs.drift }}; | |
function readFile(filename) { | |
try { | |
return fs.readFileSync(`${myDirectory}/${filename}`, 'utf8'); | |
} catch { | |
return 'ERROR reading plan output. See the run for more details' | |
} | |
} | |
// If this is drift, then we need to find the comment that is the drift comment | |
const identifier = (isDrift) ? 'isDrift' : myDirectory; | |
function ismyPreviousComment(comment) { | |
return comment.body.includes(`<div id="${identifier}"`) && comment.user.login == 'github-actions[bot]'; | |
} | |
// Get all the previous comments | |
const previousComments = await github.rest.issues.listComments({ | |
issue_number: context.issue.number, | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
}) | |
core.info(`Found ${previousComments.data.length} comments`); | |
// Find my comment | |
let myPreviousComment; | |
// ES5 doesn't support Array.prototype.find() | |
for (const comment of previousComments.data) { | |
if (ismyPreviousComment(comment)) { | |
myPreviousComment = comment; | |
} | |
} | |
// My plan output | |
const planOutput = readFile('plan_output.txt'); // the plan output | |
// Build the summary line | |
const planLineRe = /Plan: (\d+) to add, (\d+) to change, (\d+) to destroy/g; | |
const search = [...planOutput.matchAll(planLineRe)]; | |
const theFirstResult = search[0]; | |
const theNewPlanOneLiner = (theFirstResult) ? theFirstResult[0] : false; | |
const resourcesAreBeingDeleted = (theFirstResult) ? parseInt(theFirstResult[3]) > 0 : false; | |
const summaryColor = (resourcesAreBeingDeleted) ? 'red' : 'black'; | |
const summaryText = (theNewPlanOneLiner) | |
? `<p style="color:${summaryColor};">${theNewPlanOneLiner}</p>` | |
: '<p style="color:SeaGreen;">No changes. Infrastructure up to date</p>'; | |
// Build the call to action | |
const actionText = | |
'curl -X POST -H "Authorization: Token $GITHUB_TOKEN" ' + | |
'https://api.github.com/repos/SlideShareCorp/terraform/actions/workflows/11869138/dispatches ' + | |
`-d \'{"ref": "master", "inputs": {"sha": "${context.sha}", "directory": "${myDirectory}", "issue": "${context.issue.number}"}}\'` | |
// Build the section | |
const mySection = `<div id="${myDirectory}"> | |
<h2><code>${myDirectory}</code></h2> | |
<summary>${summaryText}</summary> | |
<details> | |
\`\`\` | |
${planOutput} | |
\`\`\` | |
</details> | |
In order to execute this plan, run the following: | |
\`\`\` | |
${actionText} | |
\`\`\` | |
</div><!-- closing ${myDirectory} -->`; | |
function trimBody(body) { | |
if (body.length > 65536) { | |
// remove the Details if the body is too long | |
const re = new RegExp('<details>.*</details>', 'gms'); | |
return body.replace(re, "Details have been removed to meet max character limits for comments. Please run in your terminal."); | |
} else { | |
return body; | |
} | |
} | |
const mySectionRegex = new RegExp(`<div id="${myDirectory}">.*</div><!-- closing ${myDirectory} -->`, 'gms'); | |
const mySectionAlreadyInmyPreviousComment = (myPreviousComment) ? mySectionRegex.test(myPreviousComment.body) : false; | |
function addMySectionToDrift() { | |
let body = myPreviousComment.body; | |
if (mySectionAlreadyInmyPreviousComment) { | |
body = body.replace(mySectionRegex, mySection); | |
} else { | |
const lastLine = new RegExp('</div>$'); | |
body = body.replace(lastLine, mySection + '</div>'); | |
} | |
return body; | |
} | |
// MAIN | |
if (theNewPlanOneLiner) { | |
core.info(`Found a plan with changes: ${theNewPlanOneLiner}`); | |
if (myPreviousComment) { | |
core.info('Updating my previous comment.'); | |
const body = (isDrift) ? addMySectionToDrift() : mySection; | |
github.rest.issues.updateComment({ | |
comment_id: myPreviousComment.id, | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
body: body, | |
}) | |
} else { | |
core.info('Creating a new comment.'); | |
const body = (isDrift) | |
? '<div id="isDrift"><h1>Drift Detection</h1>' + mySection + '</div>' | |
: mySection; | |
github.rest.issues.createComment({ | |
issue_number: context.issue.number, | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
body: trimBody(body), | |
}) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
name: Apply Terraform | |
on: | |
workflow_dispatch: | |
inputs: | |
directory: | |
description: "terraform directory to trigger" | |
required: true | |
default: "" | |
sha: | |
description: "git sha of the generated plan" | |
required: true | |
default: "" | |
issue: | |
description: "The issue that will be updated with the results of the run" | |
required: false | |
default: "" | |
env: | |
# See page below about disabling Hashicorp Upgrade and Security Checks | |
# https://www.terraform.io/docs/commands/index.html#upgrade-and-security-bulletin-checks | |
CHECKPOINT_DISABLE: true | |
jobs: | |
terraform-apply: | |
runs-on: ubuntu-latest | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v2 | |
- name: Configure AWS | |
uses: aws-actions/configure-aws-credentials@v1 | |
with: | |
aws-access-key-id: ${{ secrets.TERRAFORM_AWS_ACCESS_KEY_ID }} | |
aws-secret-access-key: ${{ secrets.TERRAFORM_AWS_SECRET_ACCESS_KEY }} | |
aws-region: us-east-1 | |
- name: Download plan | |
run: | | |
aws s3 cp s3://my-terraform-bucket/github-actions/plans/${{ github.event.inputs.sha }}-${{ github.event.inputs.directory }}/tf.plan ${{ github.event.inputs.directory }}/tf.plan | |
- name: Install tfenv | |
working-directory: "${{ github.workspace }}" | |
run: | | |
mkdir -p $HOME/.tfenv | |
cd $HOME/.tfenv | |
# download | |
wget https://github.com/tfutils/tfenv/archive/v2.2.2.tar.gz | |
# verify that it's legit | |
echo "ac7f74d8a0151e36a539ceae1460b320ec7b98b360dbd7799dc7cdbdf8c06ded v2.2.2.tar.gz" | sha256sum --check --strict | |
# install | |
tar -zxvf v2.2.2.tar.gz | |
echo "$(pwd)/tfenv-2.2.2/bin" >> $GITHUB_PATH | |
- name: Terraform Apply | |
env: | |
SSH_AUTH_SOCK: /tmp/ssh_agent.sock | |
id: apply | |
run: | | |
cd ${{ github.event.inputs.directory }} && \ | |
(tfenv use || tfenv install && tfenv use) && \ | |
terraform init && \ | |
terraform apply -no-color tf.plan 2>&1 | tee -a apply.out | |
- name: report | |
uses: actions/github-script@v4 | |
if: "${{ github.event.inputs.issue }}" | |
with: | |
script: | | |
const fs = require('fs'); | |
const applyData = fs.readFileSync(`${context.payload.inputs.directory}/apply.out`, 'utf8'); | |
const changes = `<summary>Changes Applied.</summary> | |
<details> | |
\`\`\` | |
${applyData} | |
\`\`\` | |
</details>`; | |
const mySection = `<div id="${ context.payload.inputs.directory }"> | |
<h2><code>${ context.payload.inputs.directory }</code></h2> | |
${changes} | |
</div><!-- closing ${ context.payload.inputs.directory } -->`; | |
github.issues.createComment({ | |
issue_number: context.payload.inputs.issue, | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
body: mySection, | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Firstly, don't do this. Use something like spacelift.
This is a proof of concept that you can use github actions in order to manage your terraform lifecycle.
Where it gets ugly is that I can't make a POST request from the issue page, so you must instead run a script using a Personal Access Token in order to trigger an apply.