Terraform: GitOps con Atlantis + Infracost

En este post, vamos a ver como implantar Terraform para poder desplegar toda la infraestructura controlada por GitOps automáticamente añadiendo tanto controles de seguridad como de costes.

Para esta PoC, vamos a usar AWS como cloud y Gitlab como repositorio git.

Antes de empezar, vamos a revisar los basics. Por ejemplo como gestionar la parte del estado. terraform.tfstate es el fichero de estado de Terraform. Esto es crítico y sensible ya que aquí se define el estado real de la infraestructura, incluidas las contraseñas o tokens utilizados, por lo que hay que tener especial cuidado sobre todo cuando varias personas o equipos van a desplegar, ya que este fichero no debe de subirse a ningún repositorio en plano por seguridad. Para esto, Terraform implementa los backends, que es una ubicación donde se guarda el fichero terraform.tfstate y este puede ser accesible por toda la gente o servicios que vayan a desplegar. En nuestro caso, usarmos un bucket de S3 que debería de estar tanto cifrado como con el versionado activado para cumplir las buenas prácticas

How to use s3 backend with a locking feature in terraform to collaborate  more efficiently? | by Oliver Cion | Clarusway | Medium

Pero nos surge otro problema, y si 2 personas aplican al mismo tiempo? Se pisarán? Para esto también hay solución. Se debe de implementar un sistema de bloqueos que haga que la primera persona que acceda al terraform.tfstate bloquee el fichero. ¿Como se hace esto? En AWS se hace con DynamoDB. Cada backend tiene su método de bloqueo.

Otra buena práctica, es dividir los ficheros de estado por entorno. No tener un único terraform.tfstate si no tener varios, de este modo, el proceso de plan es mucho mas rápido

De todos modos, una vez implantada la metodología GitOps este problema desaparecería ya que lo único que debería de poder desplegar debería de ser Atlantis.

El código quedaría de la siguiente manera

terraform {
  backend "s3" {
    bucket = "test-tfstate"  # Nombre del Bucket S3
    key = "prueba/terraform.tfstate"  # Path donde se quiere guardar el terraform.tfstate
    region = "eu-west-1"  # Región donde se encuentra el bucket
    dynamodb_table = "terraform_state"  # La tabla de DynamoDB que vamos a usar para el lock
  }
}

Una vez entendido esto, vamos manos a la obra con como empezar a usar GitOps. En este post no vamos a profundizar sobre que es GitOps, solo destacar que son una serie de practicas para gestionar las configuraciones en la que todas las iteracciones se realizan mediante git.

Atlantis

Atlantis es una herramienta que permite desplegar Terraform utilizando GitOps desde el propio repositorio del proyecto. El flujo sería el siguiente:
En mi local commiteo un cambio en una rama nueva –> hago un push hacia el repo–> Abro un Merge Request de mi rama contra la rama principal (master por ejemplo) y esto desencadenará un webhook hacia Atlantis que lanzará un terraform plan. Una vez termine el plan, nos mostrará un comentario con los cambios que se van a aplicar –> escribimos en un comentario de la Merge Request atlantis apply para aplicar los cambios. Y Atlantis se encargará de aplicar los cambios, mergear nuestra rama contra la principal y cerrar el Merge Request automáticamente.

Introducing Atlantis. Terraform automation for your team | by Luke Kysow |  runatlantis | Medium

En este flujo, es recomendable implantar las siguientes restricciones sobre el Merge Request y las Ramas:

  • Que nadie pueda hacer un push directamente contra la rama principal a excepción de Atlantis.
  • Que las Merque Request tengan que ser aprobadas por una persona diferente al autor.
  • Que la persona que apruebe no tenga ningún commit en esa rama.
  • Que el pipeline sea correcto.

En este post tampoco vamos a centrarnos en como instalar Atlantis. Esto es bastante fácil y está explicado en su web. Nos vamos a centrar en la parte de configuración. Para esto vamos a crear en el repositorio del proyecto de Terraform un fichero llamado atlantis.yaml. Aquí estará todo el flujo y lógica que queramos que ejecute Atlantis cuando se cree la Merge Request

version: 3
automerge: true  # Mergear automáticamente la rama
delete_source_branch_on_merge: true  # Borrar la rama una vez mergeada
parallel_plan: true
parallel_apply: true

projects:
- name: terraform_test
  dir: test
  workspace: default
  autoplan:  # Que se lance el plan automáticamente 
    when_modified:
      - "*.tf"
      - "*.tfvars"
    enabled: true
  apply_requirements: [mergeable, approved]  # Condiciones para aplicar
  workflow: custom

workflows:
  custom:
    plan:
      steps:
        - init
        - plan
    apply:
      steps:
        - apply

La estructura de directorios sería la siguiente:

.
|
|--atlantis.yaml
|
|--test
    |--main.tf
    |--backend.tf

Tip: En la configuración del servicio de Atlantis, en el fichero repos.yaml, añadid lo siguiente:

repos:
  - id: /.*/
    apply_requirements: []
    workflow: default
    allowed_overrides: [apply_requirements, workflow, delete_source_branch_on_merge]
    allow_custom_workflows: true
workflows:
  default:
  plan:
    steps: [init, plan]
  apply:
    steps: [apply]

Y si utilizáis módulos de terraform privados, hay que hacer el siguiente cambio para que pueda descargarselos con el token de gitlab en el fichero de gitconfig (esto es extrapolable al resto de repositorios git)

[url "https://gitlab-ci-token:GITLAB_TOKEN@gitlab.com"]
  insteadOf = https://gitlab.com
[url "https://gitlab-ci-token:GITLAB_TOKEN@gitlab.com"]
  insteadOf = ssh://git@gitlab.com

Para la autenticación de Terraform contra AWS, cada uno implementará la que mas le convenga. Mi recomendación es el utilizar roles. En el caso de desplegar Atlantis en un EKS, se puede generar un role IRSA asociado y en el role de Terraform-Role en la sección relationship_principal_identifiers permitir el Role de Atlantis. Esto le permitirá a Atlantis asumir el Role correspondiente de Terraform

provider "aws" {
  region = var.region
  assume_role {
    role_arn = "arn:aws:iam::XXXXXXXXX:role/Terraform-Role"
    session_name = "test-terraform"
  }
}

Una vez con esto, Atlantis ya podrá desplegar el proyecto automáticamente.

Infracost

Ahora vamos con la otra parte del post que es Infracost. Esta herramienta lo que hace es revisar lo que se va a aplicar en el Merge Request y te da una estimación de costes. Evidentemente no puede calcular temas de cuota de networking, peticiones… Se limita al coste extra mensual fijo del servicio.

Dockerfile

Para utilizar Infracost con Atlantis, es necesario modificar el Dockerfile de Atlantis para que lo instale.

ARG ATLANTIS_VERSION=latest
FROM ghcr.io/runatlantis/atlantis:${ATLANTIS_VERSION}

ARG INFRACOST_VERSION
ENV INFRACOST_VERSION_SET=$INFRACOST_VERSION

RUN apk add --no-cache
  \ ca-certificates
  \ openssl
  \ openssh-client
  \ curl
  \ git
  \ jq
  \ && rm -f /var/cache/apk/*

RUN curl -s -L https://github.com/infracost/infracost/releases/download/$INFRACOST_VERSION_SET/infracost-linux-amd64.tar.gz | tar xz -C /tmp &&
  \ mv /tmp/infracost-linux-amd64 /usr/bin/infracost

Con esto, habrá que pasarle 2 --build-arg a docker a la hora de hacer el docker build. El ATLANTIS_VERSION con la versión que queramos de Atlantis y la de INFRACOST_VERSION con la versión que queramos de Infracost.

APIKEY

Ahora tenemos que obtener el APIKEY de Infracost, esto lo que hace es conectarse a su servidor para obtener los precios de los recursos actualizados. Para esto hay que lanzar un comando. O bien se puede instalar la tool en local o bien se puede levantar la imagen que hemos creado un con docker run y ejecutar esto. Esto nos devolverá un APIKEY que usaremos mas tarde.

infracost register

Atlantis config

Una vez tengamos instalado Infracost en la imagen de Atlantis, hay que volver a editar el fichero de atlanis.yaml y añadir la parte correspondiente de Infracost

version: 3
automerge: true
delete_source_branch_on_merge: true

projects:
- name: terraform_test
    dir: test
  workspace: default
  autoplan:
    when_modified:
      - "*.tf"
      - "*.tfvars"
    enabled: true
  apply_requirements: [mergeable, approved]
  workflow: custom

workflows:
  custom:
    plan:
      steps:
        - env:
            name: INFRACOST_API_KEY
            command: "XXXXXXXXXXXXX" # Aquí va el APIKEY que hemos obtenido previamente.
        - env:
            name: INFRACOST_OUTPUT
            command: 'echo "/tmp/$BASE_REPO_OWNER-$BASE_REPO_NAME-$PULL_NUM-$WORKSPACE-${REPO_REL_DIR//\//-}-infracost.json"'
        - env:
            name: INFRACOST_COMMENT_TAG
            command: 'echo "$BASE_REPO_OWNER-$BASE_REPO_NAME-$PULL_NUM-$WORKSPACE-${REPO_REL_DIR//\//-}"'
        - env:
            name: INFRACOST_CURRENCY
            command: 'echo "EUR"'
        - init
        - plan
        - show
        - run: rm -rf /tmp/$BASE_REPO_OWNER
        - run: mkdir -p /tmp/$BASE_REPO_OWNER
        - run: infracost breakdown --path=$SHOWFILE --format=json --log-level=info --out-file=$INFRACOST_OUTPUT
        - run: infracost comment gitlab --repo $BASE_REPO_OWNER/$BASE_REPO_NAME --merge-request $PULL_NUM --path $INFRACOST_OUTPUT --gitlab-token $ATLANTIS_GITLAB_TOKEN --tag $INFRACOST_COMMENT_TAG --behavior new
        - run: rm -rf /tmp/$BASE_REPO_OWNER
    apply:
      steps:
        - apply

Y con esto ya estaría. Ahora cada vez que ejecutemos un Merge Request, nos saldrá un comentario con el precio extra que nos costará lo que despleguemos.

Bonus Tip

Esto ha sido referente a la parte de GitOps, ahora vamos con la parte de Pipeline. Vamos hacer que antes de poder desplegar, compruebe que tanto el formato de terraform como la parte de seguridad y buenas practicas se cumplan.

Para la parte de formato, terraform implementa 2 comandos validate que valida que la configuración esté bien sin consultar al backend y fmt que comprueba que la estructura de la sintaxis sea correcta y para la parte de seguridad, vamos a usar Tfsec que verifica que se estén implementando las buenas prácticas.

En este caso estamos usando Gitlab CI, así que en el caso de usar otro CI habrá que adaptarlo. el Pipeline quedaría así:

#==============
# STAGES
#==============

stages:
  - code_quality_security

# ========== CHECKS ==========

# ------ EXTEND ------

.ckecks:
  stage: code_quality_security
  image:
    name: hashicorp/terraform:${TERRAFORM_VERSION}
    entrypoint: [""]
  before_script:
    - apk add curl jq
    - wget https://github.com/aquasecurity/tfsec/releases/download/${TFSEC_VERSION}/tfsec-linux-amd64
    - chmod +x tfsec-linux-amd64
    - git config --global url."https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com".insteadOf ssh://git@gitlab.com
    - >
      terraform
      -chdir=${ENV} init
  script:
    - echo "================= VALIDATE ================="
    - >
      terraform
      -chdir=${ENV}
      validate
    - echo "================= CHECK FORMAT ================="
    - >
      terraform
      -chdir=${ENV}
      fmt -check
    - echo "================= TFSEC ================="
    - >
      ./tfsec-linux-amd64
      --tfvars-file ${ENV}/${ENV}.auto.tfvars
      --config-file ${ENV}/tfsec.yaml
      --exclude-downloaded-modules
      ${ENV}/

# ------ JOBS ------

ckecks_test:
  extends:
    - .ckecks
  variables:
    ENV: test
  only:
    changes:
      - test/*

El Pipeline está preparado para que en el caso de que haya diferentes carpetas con diferentes entornos, no se ejecute en todos cada vez que se hace un Merge Request, si no solo cuando se realice algún cambio en alguno de sus ficheros.

También tendremos que crear un fichero tfsec.yaml en el caso de que queramos excluir alguna de las reglas.

---
exclude:
  - aws-ecr-repository-customer-key
  - aws-vpc-no-public-egress-sg

Otra opción de hacer esto, es en el mismo código de Terraform con un comentario:

# tfsec:ignore:aws-iam-no-policy-wildcards
data "aws_iam_policy_document" "test_user" {
  statement {
    sid = "test_user"
    effect = "Allow"
    resources = ["*"]
    actions = [
      "ec2:Describe*",
      "eks:DescribeCluster",
      "eks:ListClusters"
    ]
  }
}

En el caso de que alguna de las reglas fallé, nos aparecerá cuando se ejecute el pipeline

The static analysis of Terraform — tfsec | by Victor Lin | Medium

Y en los links que nos da, nos dice como solucionar los errores de una forma bastante clara

Con esto deberíamos de ser capaces de tener un flujo bastante solido de despliegue de infraestructura. Nos aseguraríamos que todos los cambios sean validados por compañeros (con las Merge Request), quedaría un histórico de los cambios y costes que se aplican en cada Merge Request (con Atlantis e Infracost) y nos aseguraríamos que cumplimos tanto las buenas practicas (con Tfsec) como el formato (con validate y fmt)

Vault Bonus Tip

Si encima utilizas Hashicorp Vault para gestionar secretos e identidades en terraform (bien hecho!), y además has desplegado Atlantis en Kubernetes, para obtener un VAULT_TOKEN tienes que generar un script en el repo del proyecto

#!/bin/bash

KUBE_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)

curl -s --request POST \
--data '{"jwt": "'"${KUBE_TOKEN}"'", "role": "atlantis"}\' \
${VAULT_ADDR}/v1/auth/kubernetes/login | jq -r '.auth.client_token'

Y en el fichero de atlantis.yaml añadir la siguiente variable:

...
workflows:
  vault-token:
    plan:
      steps:
        - env:
            name: VAULT_TOKEN
            command: "../vault-token.sh"
    apply:
      steps:
        - env:
            name: VAULT_TOKEN
            command: "../vault-token.sh"
...

Esto obtendrá el token usando el engine de kubernetes de vault.

Y si tienes guardado el APIKEY de infracost en un KV de Vault, también habría que generar otro script con lo siguiente:

#!/bin/bash

KUBE_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)

VAULT_TOKEN=$(curl -s --request POST \
--data '{"jwt": "'"${KUBE_TOKEN}"'", "role": "atlantis"}\' \
${VAULT_ADDR}/v1/auth/kubernetes/login | jq -r '.auth.client_token')

curl -s -X GET -H "X-Vault-Token:${VAULT_TOKEN}" ${VAULT_ADDR}/v1/test/data/Infracost | jq -r ".data.data.API_KEY"

Y añadir en el atlantis.yaml la nueva variable

...
workflows:
  vault-token:
    plan:
      steps:
        - env:
            name: VAULT_TOKEN
            command: "../vault-token.sh"
        - env:
            name: INFRACOST_API_KEY
            command: "../infracost-apikey.sh"
...

Quedaría todo así:

workflows:
  vault-token:
    plan:
      steps:
        - env:
            name: VAULT_TOKEN
            command: "../vault-token.sh"
        - env:
            name: INFRACOST_API_KEY
            command: "../infracost-apikey.sh"
        - env:
            name: INFRACOST_OUTPUT
            command: 'echo "/tmp/$BASE_REPO_OWNER-$BASE_REPO_NAME-$PULL_NUM-$WORKSPACE-${REPO_REL_DIR//\//-}-infracost.json"'
        - env:
            name: INFRACOST_COMMENT_TAG
            command: 'echo "$BASE_REPO_OWNER-$BASE_REPO_NAME-$PULL_NUM-$WORKSPACE-${REPO_REL_DIR//\//-}"'
        - env:
            name: INFRACOST_CURRENCY
            command: 'echo "EUR"'
        - init
        - plan
        - show
        - run: rm -rf /tmp/$BASE_REPO_OWNER
        - run: mkdir -p /tmp/$BASE_REPO_OWNER
        - run: infracost breakdown --path=$SHOWFILE --format=json --log-level=info --out-file=$INFRACOST_OUTPUT
        - run: infracost comment gitlab --repo $BASE_REPO_OWNER/$BASE_REPO_NAME --merge-request $PULL_NUM --path $INFRACOST_OUTPUT --gitlab-token $ATLANTIS_GITLAB_TOKEN --tag $INFRACOST_COMMENT_TAG --behavior new
        - run: rm -rf /tmp/$BASE_REPO_OWNER
    apply:
      steps:
        - env:
            name: VAULT_TOKEN
            command: "../vault-token.sh"
        - apply

Si surge alguna duda, ponedlo en los comentarios para intentar aclararla y añadirlo al post.


Also published on Medium.

Leave a Reply

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *