Add terraform plan
output to merge requests and expose details from
terraform plan
runs directly into a merge request widget enabling you to see
statistics about the resources that Terraform creates, modifies, or destroys.
When a change is pushed to a feature branch the pipeline will initialize and cache (for one hour) the working directory containing terraform configuration files. If the initialization stage is successful, then the code will be formatted and validated.
When a merge request is opened, on top the steps mentioned above, an terraform execution plan will be created.
Once the merge request is merged the terraform execution plan will be applied.
Currently, the terraform part of the pipeline uses the official terraform docker image from Docker Hub.
By default the pipeline can publish the terraform execution plan as a comment
to the merge request. This however, requires a few configuration steps the user
needs to go through. To disable this feature you need to set the NOTES
variable to False
.
If you want to use the publish the terraform execution feature a project access token is required.
To create such a token, follow the instructions in the GitLab docs. In short:
- Go to the
Settings > Access Tokens
page - Enter
Notes
as theName
(If another name is required, make sure the name is reflected in the pipeline by changing the value of theAUTHOR
variable) - Choose the
api
scope - Click the
Create project access token
button - Save the generated token somewhere until we need it later
At this point the project access token is configured, but the pipeline can't access it, hence it has to be exposed as an environment variable.
- Go to the
Settings > CI/CD
page - Expand the
Variables
section - Click the
Add Variable
button - Enter
GITLAB_TOKEN
asKey
and the generated token as theValue
- Finally, make sure that the
Masked
switch is turned on and then press theAdd variable
button
Here is a tested full working pipeline.
---
default:
image:
name: hashicorp/terraform:0.15.1
entrypoint: ["/usr/bin/env"]
cache:
key: terraform
paths:
- .terraform
- .terraform.lock.hcl
interruptible: true
stages:
- init
- check
- plan
- comment
- apply
variables:
NOTES: "True"
AUTHOR: "Notes"
terraform init:
stage: init
script:
- rm -fr .terraform .terraform.lock.hcl
- terraform version
- terraform init -input=false -upgrade=true
rules:
- when: always
terraform fmt:
stage: check
cache: {}
script:
- terraform fmt -recursive -diff -check
rules:
- when: on_success
terraform validate:
stage: check
script:
- terraform validate
needs:
- terraform init
rules:
- when: on_success
terraform build plan:
stage: plan
before_script:
# install jq
- apk add --update --no-cache jq
script:
# build the plan
- terraform plan -lock-timeout=120s -input=false -out=plan.zip
# plan's text representation
- terraform show -no-color plan.zip > plan.txt
# count resources planned to change
- terraform show -json plan.zip | jq '[.resource_changes[] | select(.change.actions[0] != "no-op")] | length' > plan.changes
# convert plan to json expected in gitlab-ci
# https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26830/diffs
- terraform show -json plan.zip | jq -r '([.resource_changes[].change.actions?]|flatten)|{"create":(map(select(.=="create"))|length),"update":(map(select(.=="update"))|length),"delete":(map(select(.=="delete"))|length)}' > plan.json
artifacts:
name: plan
expire_in: 1 hour
paths:
- plan.zip
- plan.txt
- plan.changes
reports:
terraform:
- plan.json
rules:
# run on merge_requests
- if: '$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME'
# run on master branch
- if: '$CI_COMMIT_REF_NAME == "master"'
interruptible: false
post plan to comments:
stage: comment
variables:
GIT_STRATEGY: none
image: alpine:3.13.5
cache: {}
before_script:
- apk add --update --no-cache curl sed jq bash
script:
# if NOTES is not set to True exit gracefully the job now
- >-
if [ "$NOTES" != "True" ]; then
exit 0
fi
- echo "# Plan" > header.txt
- echo \`\`\`diff >> header.txt
- echo \`\`\` > footer.txt
- cat header.txt plan.txt footer.txt > temp.txt
- mv temp.txt plan.txt
- rm header.txt footer.txt
- sed -i -e 's/ \#/\#/g' plan.txt
- sed -i -e 's/ +/+/g' plan.txt
- sed -i -e 's/ ~/~/g' plan.txt
- sed -i -e 's/ -/-/g' plan.txt
- MESSAGE=$(cat plan.txt)
# try to find if we have created any comment/note in the MR already
- >-
note_id=$(
curl \
-X GET \
--silent \
--globoff \
--header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/discussions" | jq -r '.[].notes[] | select(.author.name=='\"${AUTHOR}\"') | .id'
)
- >-
note_header=$(
curl \
-X GET \
--silent \
--globoff \
--header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/discussions" | jq -r '.[].notes[] | select(.author.name=='\"${AUTHOR}\"') | .body' | head -n1)
- >-
if [ -n "$note_id" ] && [ "$note_header" == "# Plan" ]; then
# we already have a comment/note, update it
echo "Update the existing note [id: ${note_id}]"
curl \
-X PUT \
--silent \
--globoff \
--header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
--data-urlencode "body=${MESSAGE}" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/notes/${note_id}" | jq .
else
echo "Create a new note"
curl \
-X POST \
--silent \
--globoff \
--header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
--data-urlencode "body=${MESSAGE}" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/notes" | jq .
fi
dependencies:
- terraform build plan
rules:
# run on merge_requests
- if: '$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME'
terraform apply:
stage: apply
script:
- terraform apply -lock-timeout=120s -input=false plan.zip
rules:
# apply automatically on master branch when the pipeline is triggered normally
- if: '$CI_COMMIT_REF_NAME == "master" && $CI_PIPELINE_SOURCE == "push"'
when: on_success
# apply manually on master in any other case (i.e. run from schedules)
- if: '$CI_COMMIT_REF_NAME == "master"'
when: manual
interruptible: false