Skip to content

Instantly share code, notes, and snippets.

@mkol5222
Forked from eana/gitlab.md
Created January 23, 2024 16:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mkol5222/47eec732bcc78ca5353ba81c644f8e6e to your computer and use it in GitHub Desktop.
Save mkol5222/47eec732bcc78ca5353ba81c644f8e6e to your computer and use it in GitHub Desktop.
Output Terraform Plan information into a merge request

Output Terraform Plan information into a merge request

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.

How it works

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.

Project access token

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 the Name (If another name is required, make sure the name is reflected in the pipeline by changing the value of the AUTHOR 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 as Key and the generated token as the Value
  • Finally, make sure that the Masked switch is turned on and then press the Add variable button

The pipeline

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment