Skip to content

Instantly share code, notes, and snippets.

@jim80net
Last active January 31, 2023 08:16
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jim80net/d6a9d291c2f860e14c825a92063aea13 to your computer and use it in GitHub Desktop.
Save jim80net/d6a9d291c2f860e14c825a92063aea13 to your computer and use it in GitHub Desktop.
Github Action to add a terraform plan to pull requests.
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),
})
}
}
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,
});
@jim80net
Copy link
Author

jim80net commented Jan 12, 2022

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment