Thomas
Thomas
The IT guy in the office
Sep 11, 2021 5 min read

Infracost and Gitlab

thumbnail for this post

My current terraform infrastructure run inside Gitlab, and it’s deployed through CI/CD. There is nice feature from Terraform cloud that is the cost estimation. My infrastructure is definitely not big but I through why not having an extra check/information about how much will cost my current playground. Quick look on internet and Infracost come leading my query.

What is Infracost

According to the official website the definition is actually simple:

Cloud cost estimates for Terraform in pull requests

Basically, every time I will do a pull request on my repo, Terracost will estimate the cost change and comment the pull request with the data. There are 3 million prices available for Google/Azure/AWS.

The interesting part is the fact that you can also indicate the current usage of your resource like lambda, API gateway and improve the accuracy of the cost estimation

I’m mainly using Scaleway for the moment but I have few ressources on AWS and Azure that I’m curious about.

Gitlab CI/CD integration

The documentation is available here in Gitlab.

My pipeline will do the following steps for each pull request:

  1. terraform-validate
  2. terraform-plan
  3. infracost

The first step I did is to copy the infracost.yml file into my repo and rename it infracost.gitlab-ci and place it under the root folder gitlab-ci. By default, I put every template CI in this folder, it’s purely a repo organization mater.

.
├───.vscode
├───gitlab-ci
│   └───infracost.gitlab-ci.yml
├───.gitlab-ci.yml
├───.gitignore
├───README.md
└───terraform
    ├───main.tf
    ├───variables.tf
    ├───providers.tf
    └───versions.tf

Then I load the template into my main CI pipeline description file .gitlab-ci.yml

include:
  - local: '/gitlab-ci/infracost.gitlab-ci.yml'

Then I add my Infracost stage

stages:
  - terraform-validate
  - terraform-plan
  - infracost # New stage added here
  - terraform-apply

To finish I add the job

tf-cost:
  extends: .infracost # calling the template
  environment:
    name: prod
  variables:
    path: "plan.cache" # In my case the path of the plan is coming from the previous plan stage and the arctfact in my case is call plan.cache
  stage: infracost
  when: on_success
  before_script:
    - cd ${TF_ROOT}
  dependencies:
    - tf-plan
  allow_failure: true # in case of failure the pipeline will not failed.
  only:
    refs:
    # this will be only trigger for the master branch and merge request
      - master
      - merge_requests

Gitlab terraform backend config

I’m currently using the Gitlab terraform backend to host my state. In this condition, the Infracost need to be updated with the variables used to authenticate against the backend. You will also need to trigger terraform init during the before_script step.

tf-cost:
  extends: .infracost
  environment:
    name: prod
  variables:
    path: "plan.cache"
    # HTTP backend variables
    TF_HTTP_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_ENVIRONMENT_NAME}
    TF_HTTP_USERNAME: "gitlab-ci-token"
    TF_HTTP_PASSWORD: "${CI_JOB_TOKEN}"
    TF_HTTP_LOCK_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_ENVIRONMENT_NAME}/lock"
    TF_HTTP_UNLOCK_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_ENVIRONMENT_NAME}/lock"
  stage: infracost
  when: on_success
  before_script:
    # browse to th folder of the terraform file and trigger init
    - cd ${TF_ROOT}
    - terraform init
  dependencies:
    - tf-plan
  allow_failure: true
  only:
    refs:
      - master
      - merge_requests

Override the default terraform version

During my setup I was facing an issue with my terraform version, I’m currently running with terraform 1.0.3 version and the latest version inside the Terracost image is 1.0.2. I had to install the 1.0.3 version directly in the job as follows:

tf-cost:
  extends: .infracost
  environment:
    name: prod
  variables:
    path: "plan.cache"
    TF_HTTP_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_ENVIRONMENT_NAME}
    TF_HTTP_USERNAME: "gitlab-ci-token"
    TF_HTTP_PASSWORD: "${CI_JOB_TOKEN}"
    TF_HTTP_LOCK_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_ENVIRONMENT_NAME}/lock"
    TF_HTTP_UNLOCK_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_ENVIRONMENT_NAME}/lock"
  stage: infracost
  when: on_success
  before_script:
    # Add another terraform version
    - apk add --no-cache curl unzip
    - cd /tmp
    - curl -L "https://releases.hashicorp.com/terraform/${TF_VERSION}/terraform_${TF_VERSION}_linux_amd64.zip" > t.zip
    - unzip -o t.zip
    - rm t.zip
    - ls -la
    - mv -f terraform /usr/bin/terraform_${TF_VERSION}
    - ln -s -f /usr/bin/terraform_${TF_VERSION} /usr/bin/terraform
    - cd ${TF_ROOT}
    - terraform init # don;t forget to init otherwise it will failed
  dependencies:
    - tf-plan
  allow_failure: true
  only:
    refs:
      - master
      - merge_requests

Final result

Create a new branch, edit the code and create a pull request on your main branch. Gitlab CI will create a detached pipeline and run the job matching the only.refs: information.

  only:
    refs:
      - master
      - merge_requests

01_gitlab_detached_pipeline

The pipeline with the different stage:

02_gitlab_detached_pipeline

The pull request comment 🥳:

03_gitlab_infracost result

The full pipeline

# ---------------------------------------------------------------------------- #
#                             Default configuration                            #
# ---------------------------------------------------------------------------- #

default:
  tags:
    - gitlab-org-docker

variables:
    TF_ROOT: ${CI_PROJECT_DIR}/terraform
    TF_VAR_GITLAB_USER: ${CI_PROJECT_NAMESPACE}
    TF_VAR_GITHUB_USER: ${CI_PROJECT_NAMESPACE}
    TF_INPUT: 0
    INFRACOST_CURRENCY: EUR # currency for infracost
    TF_VERSION: "1.0.3"
    TF_SEC_VERSION: "v0.58.6"

# ---------------------------------------------------------------------------- #
#                                   Templates                                  #
# ---------------------------------------------------------------------------- #

include:
  - local: '/gitlab-ci/infracost.gitlab-ci.yml' # infra cost template

# ---------------------------------------------------------------------------- #
#                                     stage                                    #
# ---------------------------------------------------------------------------- #

stages:
  - terraform-validate
  - terraform-plan
  - infracost
  - terraform-apply

# ---------------------------------------------------------------------------- #
#                            terraform pipeline job                            #
# ---------------------------------------------------------------------------- #

tf-validate:
  image: registry.gitlab.com/gitlab-org/terraform-images/stable:latest
  environment:
    name: prod
  variables:
    TF_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_ENVIRONMENT_NAME}
  stage: terraform-validate
  cache:
    key: terraform-${CI_ENVIRONMENT_NAME}
    paths:
      - ${TF_ROOT}/.terraform
  before_script:
  - cd ${TF_ROOT}
  script:
    - gitlab-terraform init
    - gitlab-terraform validate
  only:
    refs:
      - master
      - merge_requests
      - terraform/**/*

tf-plan:
  image: registry.gitlab.com/gitlab-org/terraform-images/stable:latest
  environment:
    name: prod
  variables:
    TF_VAR_ENV: $CI_ENVIRONMENT_NAME
    TF_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_ENVIRONMENT_NAME}
  stage: terraform-plan
  before_script:
    - cd ${TF_ROOT}
  script:
    - gitlab-terraform plan
    - gitlab-terraform plan-json
  when: on_success
  cache:
    key: terraform-${CI_ENVIRONMENT_NAME}
    paths:
      - ${TF_ROOT}/.terraform
  artifacts:
    name: plan
    paths:
      - ${TF_ROOT}/plan.cache
    reports:
      terraform: ${TF_ROOT}/plan.json
  only:
    refs:
      - master
      - merge_requests
      - terraform/**/*

tf-cost:
  extends: .infracost
  environment:
    name: prod
  variables:
    path: "plan.cache"
    TF_HTTP_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_ENVIRONMENT_NAME}
    TF_HTTP_USERNAME: "gitlab-ci-token"
    TF_HTTP_PASSWORD: "${CI_JOB_TOKEN}"
    TF_HTTP_LOCK_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_ENVIRONMENT_NAME}/lock"
    TF_HTTP_UNLOCK_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_ENVIRONMENT_NAME}/lock"
  stage: infracost
  when: on_success
  before_script:
    - apk add --no-cache curl unzip
    - cd /tmp
    - curl -L "https://releases.hashicorp.com/terraform/${TF_VERSION}/terraform_${TF_VERSION}_linux_amd64.zip" > t.zip
    - unzip -o t.zip
    - rm t.zip
    - ls -la
    - mv -f terraform /usr/bin/terraform_${TF_VERSION}
    - ln -s -f /usr/bin/terraform_${TF_VERSION} /usr/bin/terraform
    - cd ${TF_ROOT}
    - terraform init
  cache:
    key: terraform-${CI_ENVIRONMENT_NAME}
    paths:
      - ${TF_ROOT}/.terraform
  dependencies:
    - tf-plan
  allow_failure: true
  only:
    refs:
      - master
      - merge_requests
      - terraform/**/*

tf-apply:
  image: registry.gitlab.com/gitlab-org/terraform-images/stable:latest
  environment:
    name: prod
  variables:
    TF_VAR_ENV: ${CI_ENVIRONMENT_NAME}
    TF_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_ENVIRONMENT_NAME}
  stage: terraform-apply
  before_script:
  - cd ${TF_ROOT}
  script:
    - gitlab-terraform apply
  when: on_success
  cache:
    key: terraform-${CI_ENVIRONMENT_NAME}
    paths:
      - ${TF_ROOT}/.terraform
  dependencies:
    - tf-plan
  only:
    refs:
      - master
      - terraform/**/*

Sources: