diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..27f403a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# IDEs +.idea +.vscode diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..94ec04b6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## v1.0.0 + +- Initial release. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..057da245 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM hashicorp/terraform:0.12.28 + +LABEL repository="https://github.com/robburger/terraform-pr-commenter" \ + homepage="https://github.com/robburger/terraform-pr-commenter" \ + maintainer="Rob Burger" \ + com.github.actions.name="Terraform PR Commenter" \ + com.github.actions.description="Adds opinionated comments to a PR from Terraform fmt/init/plan output" \ + com.github.actions.icon="git-pull-request" \ + com.github.actions.color="purple" + +RUN apk add --no-cache -q \ + bash \ + curl \ + jq + +ADD entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..5e66944f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Rob Burger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 648f8f41..ff585038 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,208 @@ -# terraform-pr-commenter -A GitHub Action that adds opinionated comments to a PR from Terraform fmt/init/plan output +# Terraform PR Commenter +Adds opinionated comments to PR's based on Terraform `fmt`, `init` and `plan` outputs. + +## Summary +This Docker-based GitHub Action is designed to work in tandem with [hashicorp/setup-terraform](https://github.com/hashicorp/setup-terraform) with the wrapper enabled, taking the output from a `fmt`, `init` or `plan`, formatting it and adding it to a pull request. Any previous comments from this Action are removed to keep the PR timeline clean. + +Support (for now) is [limited to Linux](https://help.github.com/en/actions/creating-actions/about-actions#types-of-actions) as Docker-based GitHub Actions can only be used on Linux runners. + +## Usage +This action can only be run after a Terraform `fmt`, `init`, or `plan` has completed, and the output has been captured. Terraform rarely writes to `stdout` and `stderr` in the same action, so we concatenate the `commenter_input`: +```yaml +- uses: robburger/terraform-pr-commenter@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + commenter_type: fmt/init/plan # Choose one + commenter_input: ${{ format('{0}{1}', steps.step_id.outputs.stdout, steps.step_id.outputs.stderr) }} + commenter_exitcode: ${{ steps.step_id.outputs.exitcode }} +``` + +### Inputs +| Name | Requirement | Description | +| -------------------- | ----------- | ----------- | +| `commenter_type` | _required_ | The type of comment. Options: [`fmt`, `init`, `plan`] | +| `commenter_input` | _required_ | The comment to post from a previous step output. | +| `commenter_exitcode` | _required_ | The exit code from a previous step output. | + +### Environment Variables +| Name | Requirement | Description | +| ------------------------ | ----------- | ----------- | +| `GITHUB_TOKEN` | _required_ | Used to execute API calls. The `${{ secrets.GITHUB_TOKEN }}` already has permissions, but if you're using your own token, ensure it has the `repo` scope. | +| `TF_WORKSPACE` | _optional_ | Default: `default`. This is used to separate multiple comments on a pull request in a matrix run. | +| `EXPAND_SUMMARY_DETAILS` | _optional_ | Default: `true`. This controls whether the comment output is collapsed or not. | + +Both of these environment variables can be set at `job` or `step` level. For example, you could collapse all outputs but expand on a `plan`: +```yaml +jobs: + terraform: + name: 'Terraform' + runs-on: ubuntu-latest + env: + EXPAND_SUMMARY_DETAILS: 'false' # All steps will have this environment variable + steps: + - name: Checkout + uses: actions/checkout@v2 +... + - name: Post Plan + uses: robburger/terraform-pr-commenter@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EXPAND_SUMMARY_DETAILS: 'true' # Override global environment variable; expand details just for this step + with: + commenter_type: plan + commenter_input: ${{ format('{0}{1}', steps.plan.outputs.stdout, steps.plan.outputs.stderr) }} + commenter_exitcode: ${{ steps.plan.outputs.exitcode }} +... +``` + +## Examples +Single workspace build, full example: +```yaml +name: 'Terraform' + +on: + pull_request: + push: + branches: + - master + +jobs: + terraform: + name: 'Terraform' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v1 + env: + TF_IN_AUTOMATION: true + with: + cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }} + terraform_version: 0.12.28 + + - name: Terraform Format + id: fmt + run: terraform fmt -check -recursive + continue-on-error: true + + - name: Post Format + if: always() && github.ref != 'refs/heads/master' && (steps.fmt.outcome == 'success' || steps.fmt.outcome == 'failure') + uses: robburger/terraform-pr-commenter@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + commenter_type: fmt + commenter_input: ${{ format('{0}{1}', steps.fmt.outputs.stdout, steps.fmt.outputs.stderr) }} + commenter_exitcode: ${{ steps.fmt.outputs.exitcode }} + + - name: Terraform Init + id: init + run: terraform init + + - name: Post Init + if: always() && github.ref != 'refs/heads/master' && (steps.init.outcome == 'success' || steps.init.outcome == 'failure') + uses: robburger/terraform-pr-commenter@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + commenter_type: init + commenter_input: ${{ format('{0}{1}', steps.init.outputs.stdout, steps.init.outputs.stderr) }} + commenter_exitcode: ${{ steps.init.outputs.exitcode }} + + - name: Terraform Plan + id: plan + run: terraform plan -out workspace.plan + + - name: Post Plan + if: always() && github.ref != 'refs/heads/master' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure') + uses: robburger/terraform-pr-commenter@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + commenter_type: plan + commenter_input: ${{ format('{0}{1}', steps.plan.outputs.stdout, steps.plan.outputs.stderr) }} + commenter_exitcode: ${{ steps.plan.outputs.exitcode }} + + - name: Terraform Apply + id: apply + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + run: terraform apply workspace.plan +``` + +Multi-workspace matrix/parallel build: +```yaml +... +jobs: + terraform: + name: 'Terraform' + runs-on: ubuntu-latest + strategy: + matrix: + workspace: [audit, staging] + env: + TF_WORKSPACE: ${{ matrix['workspace'] }} + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v1 + env: + TF_IN_AUTOMATION: true + with: + cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }} + terraform_version: 0.12.28 + + - name: Terraform Init - ${{ matrix['workspace'] }} + id: init + run: terraform init + + - name: Post Init - ${{ matrix['workspace'] }} + if: always() && github.ref != 'refs/heads/master' && (steps.init.outcome == 'success' || steps.init.outcome == 'failure') + uses: robburger/terraform-pr-commenter@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + commenter_type: init + commenter_input: ${{ format('{0}{1}', steps.init.outputs.stdout, steps.init.outputs.stderr) }} + commenter_exitcode: ${{ steps.init.outputs.exitcode }} + + - name: Terraform Plan - ${{ matrix['workspace'] }} + id: plan + run: terraform plan -out ${{ matrix['workspace'] }}.plan + + - name: Post Plan - ${{ matrix['workspace'] }} + if: always() && github.ref != 'refs/heads/master' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure') + uses: robburger/terraform-pr-commenter@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + commenter_type: plan + commenter_input: ${{ format('{0}{1}', steps.plan.outputs.stdout, steps.plan.outputs.stderr) }} + commenter_exitcode: ${{ steps.plan.outputs.exitcode }} +... +``` + +"What's the crazy-looking `if:` doing there?" Good question! It's broken into 3 logic groups separated by `&&`, so all need to return `true` for the step to run: +1) `always()` - ensures that the step is run regardless of the outcome in any previous steps +2) `github.ref != 'refs/heads/master'` - prevents the step running on a `master` branch +3) `(steps.step_id.outcome == 'success' || steps.step_id.outcome == 'failure')` - limits the run to the specific `step_id` only when there's a `success` or `failed` outcome. + +In English: "Always run this step, but only on a pull request and only when the previous step succeeds or fails." + +## Screenshots + +### fmt +![fmt](images/fmt-output.png) + +### plan +![fmt](images/plan-output.png) + +## Troubleshooting & Contributing +Feel free to head over to the [Issues](https://github.com/robburger/terraform-pr-commenter/issues) tab to see if the issue you're having has already been reported. If not, [open a new one](https://github.com/robburger/terraform-pr-commenter/issues/new) and be sure to include as much relevant information as possible, including code-samples, and a description of what you expect to be happening. + +## License +[MIT](LICENSE) diff --git a/action.yml b/action.yml new file mode 100644 index 00000000..713ec796 --- /dev/null +++ b/action.yml @@ -0,0 +1,20 @@ +name: 'Terraform PR Commenter' +description: 'Adds opinionated comments to a PR from Terraform fmt/init/plan output' +author: 'Rob Burger' +inputs: + commenter_type: + description: 'The type of comment. Options: [fmt, init, plan]' + required: true + commenter_input: + description: 'The comment to post from a previous step output' + required: true + commenter_exitcode: + description: 'The exit code from a previous step output' + required: true +runs: + using: 'docker' + image: 'Dockerfile' + args: + - ${{ inputs.commenter_type }} + - ${{ inputs.commenter_input }} + - ${{ inputs.commenter_exitcode }} diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 00000000..dd8e9e0c --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash + +############# +# Validations +############# +PR_NUMBER=$(jq -r ".pull_request.number" "$GITHUB_EVENT_PATH") +if [[ "$PR_NUMBER" == "null" ]]; then + echo "This isn't a PR." + exit 0 +fi + +if [[ -z "$GITHUB_TOKEN" ]]; then + echo "GITHUB_TOKEN environment variable missing." + exit 1 +fi + +if [[ -z $3 ]]; then + echo "There must be an exit code from a previous step." + exit 1 +fi + +if [[ ! "$1" =~ ^(fmt|init|plan)$ ]]; then + echo -e "Unsupported command \"$1\". Valid commands are \"fmt\", \"init\", \"plan\"." + exit 1 +fi + +################## +# Shared Variables +################## +# Arg 1 is command +COMMAND=$1 +# Arg 2 is input. We strip ANSI colours. +INPUT=$(echo "$2" | sed 's/\x1b\[[0-9;]*m//g') +# Arg 3 is the Terraform CLI exit code +EXIT_CODE=$3 + +# Read TF_WORKSPACE environment variable or use "default" +WORKSPACE=${TF_WORKSPACE:-default} + +# Read EXPAND_SUMMARY_DETAILS environment variable or use "true" +if [[ ${EXPAND_SUMMARY_DETAILS:-true} == "true" ]]; then + DETAILS_STATE=" open" +else + DETAILS_STATE="" +fi + +ACCEPT_HEADER="Accept: application/vnd.github.v3+json" +AUTH_HEADER="Authorization: token $GITHUB_TOKEN" +CONTENT_HEADER="Content-Type: application/json" + +PR_COMMENTS_URL=$(jq -r ".pull_request.comments_url" "$GITHUB_EVENT_PATH") +PR_COMMENT_URI=$(jq -r ".repository.issue_comment_url" "$GITHUB_EVENT_PATH" | sed "s|{/number}||g") + +############## +# Handler: fmt +############## +if [[ $COMMAND == 'fmt' ]]; then + # Look for an existing fmt PR comment and delete + echo -e "\033[34;1mINFO:\033[0m Looking for an existing fmt PR comment." + PR_COMMENT_ID=$(curl -sS -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -L "$PR_COMMENTS_URL" | jq '.[] | select(.body|test ("### Terraform `fmt` Failed")) | .id') + if [ "$PR_COMMENT_ID" ]; then + echo -e "\033[34;1mINFO:\033[0m Found existing fmt PR comment: $PR_COMMENT_ID. Deleting." + PR_COMMENT_URL="$PR_COMMENT_URI/$PR_COMMENT_ID" + curl -sS -X DELETE -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -L "$PR_COMMENT_URL" > /dev/null + else + echo -e "\033[34;1mINFO:\033[0m No existing fmt PR comment found." + fi + + # Exit Code: 0 + # Meaning: All files formatted correctly. + # Actions: Exit. + if [[ $EXIT_CODE -eq 0 ]]; then + echo -e "\033[34;1mINFO:\033[0m Terraform fmt completed with no errors. Continuing." + + exit 0 + fi + + # Exit Code: 1, 2 + # Meaning: 1 = Malformed Terraform CLI command. 2 = Terraform parse error. + # Actions: Build PR comment. + if [[ $EXIT_CODE -eq 1 || $EXIT_CODE -eq 2 ]]; then + PR_COMMENT="### Terraform \`fmt\` Failed +Show Output + +\`\`\` +$INPUT +\`\`\` +" + fi + + # Exit Code: 3 + # Meaning: One or more files are incorrectly formatted. + # Actions: Iterate over all files and build diff-based PR comment. + if [[ $EXIT_CODE -eq 3 ]]; then + ALL_FILES_DIFF="" + for file in $INPUT; do + THIS_FILE_DIFF=$(terraform fmt -no-color -write=false -diff "$file" | sed -n '/@@.*/,//{/@@.*/d;p}') + ALL_FILES_DIFF="$ALL_FILES_DIFF +$file + +\`\`\`diff +$THIS_FILE_DIFF +\`\`\` +" + done + + PR_COMMENT="### Terraform \`fmt\` Failed +$ALL_FILES_DIFF" + fi + + # Add fmt failure comment to PR. + PR_PAYLOAD=$(echo '{}' | jq --arg body "$PR_COMMENT" '.body = $body') + echo -e "\033[34;1mINFO:\033[0m Adding fmt failure comment to PR." + curl -sS -X POST -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -H "$CONTENT_HEADER" -d "$PR_PAYLOAD" -L "$PR_COMMENTS_URL" > /dev/null + + exit 0 +fi + +############### +# Handler: init +############### +if [[ $COMMAND == 'init' ]]; then + # Look for an existing init PR comment and delete + echo -e "\033[34;1mINFO:\033[0m Looking for an existing init PR comment." + PR_COMMENT_ID=$(curl -sS -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -L "$PR_COMMENTS_URL" | jq '.[] | select(.body|test ("### Terraform `init` Failed")) | .id') + if [ "$PR_COMMENT_ID" ]; then + echo -e "\033[34;1mINFO:\033[0m Found existing init PR comment: $PR_COMMENT_ID. Deleting." + PR_COMMENT_URL="$PR_COMMENT_URI/$PR_COMMENT_ID" + curl -sS -X DELETE -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -L "$PR_COMMENT_URL" > /dev/null + else + echo -e "\033[34;1mINFO:\033[0m No existing init PR comment found." + fi + + # Exit Code: 0 + # Meaning: Terraform successfully initialized. + # Actions: Exit. + if [[ $EXIT_CODE -eq 0 ]]; then + echo -e "\033[34;1mINFO:\033[0m Terraform init completed with no errors. Continuing." + + exit 0 + fi + + # Exit Code: 1 + # Meaning: Terraform initialize failed or malformed Terraform CLI command. + # Actions: Build PR comment. + if [[ $EXIT_CODE -eq 1 ]]; then + PR_COMMENT="### Terraform \`init\` Failed +Show Output + +\`\`\` +$INPUT +\`\`\` +" + fi + + # Add init failure comment to PR. + PR_PAYLOAD=$(echo '{}' | jq --arg body "$PR_COMMENT" '.body = $body') + echo -e "\033[34;1mINFO:\033[0m Adding init failure comment to PR." + curl -sS -X POST -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -H "$CONTENT_HEADER" -d "$PR_PAYLOAD" -L "$PR_COMMENTS_URL" > /dev/null + + exit 0 +fi + +############### +# Handler: plan +############### +if [[ $COMMAND == 'plan' ]]; then + # Look for an existing plan PR comment and delete + echo -e "\033[34;1mINFO:\033[0m Looking for an existing plan PR comment." + PR_COMMENT_ID=$(curl -sS -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -L "$PR_COMMENTS_URL" | jq '.[] | select(.body|test ("### Terraform `plan` .* for Workspace: `'"$WORKSPACE"'`")) | .id') + if [ "$PR_COMMENT_ID" ]; then + echo -e "\033[34;1mINFO:\033[0m Found existing plan PR comment: $PR_COMMENT_ID. Deleting." + PR_COMMENT_URL="$PR_COMMENT_URI/$PR_COMMENT_ID" + curl -sS -X DELETE -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -L "$PR_COMMENT_URL" > /dev/null + else + echo -e "\033[34;1mINFO:\033[0m No existing plan PR comment found." + fi + + # Exit Code: 0, 2 + # Meaning: 0 = Terraform plan succeeded with no changes. 2 = Terraform plan succeeded with changes. + # Actions: Strip out the refresh section (everything before the 72 '-' characters) and build PR comment. + if [[ $EXIT_CODE -eq 0 || $EXIT_CODE -eq 2 ]]; then + CLEAN_PLAN=$(echo "$INPUT" | sed -nr '/-{72}/,/-{72}/{ /-{72}/d; p }') # Strip refresh section + CLEAN_PLAN=${CLEAN_PLAN::65300} # GitHub has a 65535-char comment limit - truncate plan, leaving space for comment wrapper + CLEAN_PLAN=$(echo "$CLEAN_PLAN" | sed -E 's/^([[:blank:]]*)([-+~])/\2\1/g') # Move any diff characters to start of line + PR_COMMENT="### Terraform \`plan\` Succeeded for Workspace: \`$WORKSPACE\` +Show Output + +\`\`\`diff +$CLEAN_PLAN +\`\`\` +" + fi + + # Exit Code: 1 + # Meaning: Terraform plan failed. + # Actions: Build PR comment. + if [[ $EXIT_CODE -eq 1 ]]; then + PR_COMMENT="### Terraform \`plan\` Failed for Workspace: \`$WORKSPACE\` +Show Output + +\`\`\` +$INPUT +\`\`\` +" + fi + + # Add plan comment to PR. + PR_PAYLOAD=$(echo '{}' | jq --arg body "$PR_COMMENT" '.body = $body') + echo -e "\033[34;1mINFO:\033[0m Adding plan comment to PR." + curl -sS -X POST -H "$AUTH_HEADER" -H "$ACCEPT_HEADER" -H "$CONTENT_HEADER" -d "$PR_PAYLOAD" -L "$PR_COMMENTS_URL" > /dev/null + + exit 0 +fi diff --git a/images/fmt-output.png b/images/fmt-output.png new file mode 100644 index 00000000..bf3bb8a1 Binary files /dev/null and b/images/fmt-output.png differ diff --git a/images/plan-output.png b/images/plan-output.png new file mode 100644 index 00000000..3061ea2f Binary files /dev/null and b/images/plan-output.png differ