Atlantis: best practices y limitaciones

Este es el tercer y último post sobre Atlantis que voy a escribir para esta serie. Si los dos primeros explicaban cómo configurar la herramienta y cómo desplegarla sobre Cloud Run, en éste vamos a aplicar algunas buenas prácticas y a mejorar la solución, por lo que asumimos que se han leído los dos anteriores (I y II).

El primer post de la serie, creaba en el servicio de Atlantis una serie de variables de entorno, que tras convertirse temporalmente en ficheros tfvars, nos permitían inicializar y ejecutar nuestro código.

Esta solución, aunque servía como ejemplo, también tenía muchas limitaciones:

  • No escala. Las variables de entorno sólo pueden tener un valor al mismo tiempo. Si nuestra instancia gestiona múltiples proyectos con las mismas variables de entorno, no podría reutilizarlas con seguridad y además, deben de definirse en el servidor, obligando a redesplegar / reiniciar la solución en cada cambio.

  • Los proyectos cliente deben estar autocontenidos, para evitar en la medida de lo posible dependencias externas ajenas a su control y que impacten en su desarrollo.

  • Tal y cómo estaba configurado, tampoco nos proporcionaba garantías ante despliegues más automatizados y por eso vamos a añadir testing.

Integrando SOPS en nuestro repositorio

La primera parte de este post se va a centrar en solucionar los dos primeros puntos de fricción: vamos a eliminar las variables de entorno y a contener todas las dependencias de cada proyecto en un único punto. Para ello, vamos a utilizar una herramienta de gestión de secretos que nos permita su almacenamiento de forma cifrada dentro de nuestros repositorios de código. Aunque hay distintas posibilidades, mi favorita es SOPS.

Al interactuar con múltiples sistemas de gestión de llaves de cifrado y soportar de forma nativa la solución de GCP (Cloud KMS), la elección de SOPS es natural.

Antes de nada necesitamos crear una nueva clave de Cloud KMS para ser utilizada por SOPS. Accedemos a GCP a través de la consola, habilitamos la API de Cloud KMS, creamos un keyring de nombre “atlantis” y una nueva llave, con el mismo nombre:

cloud-kms

Si alguien revisa el código adjunto verá que he preferido dejar esta llave fuera de Terraform para evitar que por error pudiera ser borrada en un plan y perdiéramos el acceso a todos los secretos cifrados con ella.

Una vez tenemos la llave ha sido creada, ahora necesitamos configurar una serie de permisos en GCP para que pueda ser utilizada por todos los actores involucrados:

  • Los desarrolladores de los repositorios cliente necesitan el rol Cloud KMS CryptoKey Encrypter/Decrypter puesto que van a crear y mantener los ficheros tfvars cifrados.

  • La cuenta de servicio utilizada en GCP por Atlantis tiene que poder descifrar los ficheros, así que le asignamos el rol Cloud KMS CryptoKey Decrypter sobre la clave antes creada.

En Terraform, nuestro código quedaría de la siguiente manera:

## Data sources para acceder a la clave de Cloud KMS desde Terraform
## Se puede gestionar desde el repositorio de Atlantis u cualquier otro
data "google_kms_key_ring" "atlantis" {
  name     = "atlantis"
  location = var.gcp_default_region
}

data "google_kms_crypto_key" "atlantis" {
  name     = "atlantis"
  key_ring = data.google_kms_key_ring.atlantis.id
}


## Recursos para que los usuarios puedan cifrar y descifrar los ficheros
resource "google_kms_crypto_key_iam_policy" "atlantis_kms_write" {
  crypto_key_id = data.google_kms_crypto_key.atlantis.id
  policy_data = data.google_iam_policy.kms_write.policy_data
}

data "google_iam_policy" "kms_write" {
  binding {
    role = "roles/cloudkms.cryptoKeyEncrypterDecrypter"

    members = [
      "group:developers@tangelov.me",
    ]
  }
}


## Recursos para que Atlantis pueda descifrar los ficheros
resource "google_kms_crypto_key_iam_policy" "atlantis_kms" {
  crypto_key_id = data.google_kms_crypto_key.atlantis.id
  policy_data = data.google_iam_policy.kms.policy_data
}

data "google_iam_policy" "kms" {
  binding {
    role = "roles/cloudkms.cryptoKeyDecrypter"

    members = [
      "serviceAccount:${google_service_account.atlantis_sa.email}",
    ]
  }
}

Con estos cambios, tanto Atlantis como los desarrolladores pueden comunicarse utilizando ficheros cifrados, pero estos no existen aún. Para crearlos, necesitamos configurar SOPS en el repositorio cliente, creando un fichero en la raíz del mismo de nombre .sops.yaml con el siguiente contenido:

creation_rules:
  - gcp_kms: projects/proyecto1/locations/region1/keyRings/atlantis/cryptoKeys/atlantis

De esta forma le decimos a SOPS que debe cifrar los ficheros con la llave atlantis de Cloud KMS en el proyecto1 en la region1. Para crear los ficheros, tan sólo usamos los comandos sops init-tfvars/prd.tfvars.enc y sops apply-tfvars/prd.tfvars.enc

Si hubiéramos metido la pata y los permisos no fuesen los correctos, recibiríamos un error como el siguiente:

Failed to get the data key required to decrypt the SOPS file.

Group 0: FAILED
  projects/proyecto1/locations/region1/keyRings/atlantis/cryptoKeys/atlantis: FAILED
    - | Error decrypting key: googleapi: Error 403: Permission
      | 'cloudkms.cryptoKeyVersions.useToDecrypt' denied on resource
      | 'projects/projecto1/locations/region1/keyRings/atlantis/cryptoKeys/atlantis'
      | (or it may not exist)., forbidden

SOPS no impide que nuestros desarrolladores sigan probando su código a mano:

# Desciframos el fichero de init-tfvars y ejecutamos terraform init
sops -d init-tfvars/prd.tfvars.enc > init-tfvars/prd.tfvars
terraform init -backend-config ./init-tfvars/prd.tfvars

# Desciframos el fichero de apply-tfvars y ejecutamos terraform plan, apply, etc
sops -d init-tfvars/prd.tfvars.enc > init-tfvars/prd.tfvars
terraform apply -var-file ./apply-tfvars/prd.tfvars

# Limpiamos
rm init-tfvars/prd.tfvars apply-tfvars/prd.tfvars

Nuestros desarrolladores ya pueden trabajar con SOPS, pero… ¿Cómo hacemos lo mismo con Atlantis?

Añadiendo SOPS a nuestro contenedor

Aunque Atlantis permite la ejecución de comandos personalizados dentro de sus workflows, difícilmente va a poder hacerlo si no tiene la herramienta a mano. Por ello antes de cambiar nada en el workflow, tenemos que modificar el Dockerfile de Atlantis para añadirlo al contenedor. Con añadir estas pocas líneas bastaría:

# Download sops from Internet
ENV DEFAULT_SOPS_VERSION=3.7.3
RUN curl -Ls https://github.com/mozilla/sops/releases/download/v${DEFAULT_SOPS_VERSION}/sops-v${DEFAULT_SOPS_VERSION}.linux.amd64 -o /usr/local/bin/sops && \
    chmod +x /usr/local/bin/sops

Una vez hemos modificado el fichero, tan sólo tenemos que construir de nuevo la imagen y redesplegar el servicio. De esta forma, SOPS puede ser utilizado sin problemas por Atlantis.

Ahora sólo tenemos que actualizar el workflow que hemos definido en el repositorio cliente (atlantis.yaml) para integrar SOPS y ya estaría:

version: 3
automerge: false
delete_source_branch_on_merge: true
parallel_plan: true
parallel_apply: true
projects:
- name: dummy
  dir: .
  terraform_version: v1.2.8
  delete_source_branch_on_merge: true
  apply_requirements: [mergeable, approved]
  workflow: standard
workflows:
  standard:
    plan:
      steps:
      - run: sops -d init-tfvars/prd.tfvars.enc > init-tfvars/prd.tfvars
      - run: sops -d apply-tfvars/prd.tfvars.enc > apply-tfvars/prd.tfvars
      - init:
          extra_args: ["-backend-config", "./init-tfvars/prd.tfvars"]
      - plan:
          extra_args: ["-var-file", "./apply-tfvars/prd.tfvars"]
      - run: rm init-tfvars/prd.tfvars apply-tfvars/prd.tfvars
allowed_regexp_prefixes:
- feature/
- fix/

Como podemos ver, la ejecución funciona correctamente:

atlantis-with-sops

Testing con Conftest

Tras asegurar que nuestros secretos pueden ser almacenados de forma segura, ahora vamos a mejorar más la calidad de la solución añadiendo testing. Utilizando Unit Testing podemos garantizar que se cumplan una serie de buenas prácticas dentro de nuestros planes de Terraform. Esta funcionalidad, aunque está en beta, es nativa en Atlantis gracias al uso de conftest, una CLI de la que ya he hablado en posts anteriores.

Gracias a Conftest, podemos escribir tests en Rego que aseguren un mínimo de calidad y seguridad en nuestra infraestructura. Es algo vital a la hora de evitar problemas, especialmente si queremos implementar CI/CD completo para nuestra infraestructura como código. Actualmente la integración nativa de Atlantis para testing tiene las siguientes características:

  • No está habilitada por defecto y hacerlo, requiere modificar la configuración general o el arranque del servicio.

  • Una vez habilitada, se configura dentro del repo.yaml en el servidor de Atlantis y solo puede referenciar a carpetas locales, sin dar soporte a carpetas remotas.

Aunque la documentación de la funcionalidad es regular y no explica paso a paso como usarla, yo si voy a hacerlo. Primero añadimos al fichero de configuración del servidor la siguiente linea:

enable-policy-checks: "true"

Así habilitaríamos la funcionalidad en el siguiente reinicio o despliegue. El siguiente paso es definir qué políticas se van a aplicar, quien es el encargado de revisarlas si fallan y su ubicación. Nuestro fichero repo.yaml quedaría así:

repos:
 - id: /.*/
   allowed_overrides: [workflow, apply_requirements, delete_source_branch_on_merge]
   allow_custom_workflows: true
policies:
  owners:
    users:
      - tangelov
  policy_sets:
    - name: non-deletion
      path: /home/atlantis/.atlantis
      source: local

Como se puede ver, hemos generado una política llamada non-deletion, que almacena las políticas en la carpeta /home/atlantis/.atlantis y que puede ser revisada en caso de fallo por tangelov.

Tras estas modificaciones, ya podemos reiniciar el servicio o recrear el contenedor y comenzar a configurar el repositorio cliente. Vamos a definir un nuevo paso en nuestro workflow donde se ejecutarán los tests:

workflows:
  standard:
    plan:
      steps:
      - run: sops -d init-tfvars/prd.tfvars.enc > init-tfvars/prd.tfvars
      - run: sops -d apply-tfvars/prd.tfvars.enc > apply-tfvars/prd.tfvars
      - init:
          extra_args: ["-backend-config", "./init-tfvars/prd.tfvars"]
      - plan:
          extra_args: ["-var-file", "./apply-tfvars/prd.tfvars"]
      - run: rm init-tfvars/prd.tfvars apply-tfvars/prd.tfvars
    policy_check:
      steps:
      - show
      - policy_check:
          extra_args: ["-p ${PWD}/policy/" , "--all-namespaces"]

Para que no haya dudas, vamos a explicar un poco más los añadidos al workflow:

  • Hemos creado un nuevo paso llamado policy_check que se ejecuta tras el plan.

  • Primero ejecutamos show para que Atlantis genere un plan de Terraform en formato JSON. Es obligatorio para poder pasar los tests después y de lo contrario el pipeline fallará.

  • Ejecutamos los tests, pero modificando ligeramente su comportamiento. Por defecto, Atlantis sólo ejecuta un namespace o package y sólo busca tests en los ficheros locales del servidor. De esta forma nos aseguramos que pasen todos los tests y que podemos añadir tests extra en el repositorio que vamos a desplegar si queremos.

Ahora mismo el sistema es bastante limitado. Si seleccionamos una carpeta que no contenga tests, ni falla da detecta errores y si queremos añadir nuevos policy sets, tenemos que añadirlos al fichero repo.yaml y reiniciar Atlantis puesto que el servicio no recarga su configuración ni utilizando variables de entorno.

Para no tener este cuello de botella, hemos añadido al ejemplo un workaround. Al añadir -p ${PWD}/policy/ a la ejecución de Conftest, le indicamos que mire dentro del propio repositorio cliente en la carpeta policy además de en la carpeta que Atlantis configura por defecto. Así podemos añadir tests de manera más dinámica, mientras mantenemos la integración entre Atlantis y Conftest.

Si ahora ejecutamos nuestro repositorio dummy a través de Atlantis, recibiremos este error:

atlantis-conftest-error

Como no estamos cumpliendo las reglas de etiquetado impuestas en los tests, podemos o pedir al administrador que nos apruebe el cambio de forma manual, o adaptar nuestro código para que pase los tests.

atlantis-conftest-val

Los tests añadidos comprueban si un recurso va a ser borrado, reemplazado o si está mal etiquetado según las normas definidas. Por ello, nuestro último paso va a ser habilitar el automerge en el repositorio de ejemplo. Así nuestros ramas se mergearán automáticamente si se pasan todos los tests y el plan es aplicado.

atlantis-automerge

Extra: Terraform Cloud

Atlantis puede integrarse con Terraform Cloud y lo hace de forma sencilla, tan sólo tenemos que modificar dos cosas:

  • El fichero donde indicamos el remote del estteriormente necesitaremos mantenerlo actualizado, gestionar sus dependencias, y más esporádicamente, realizar migraciones entre versiones.

Aunque Docker y los contenedores facilitan mucho esta tarea, incorporan otros puntos de fricción. Si utilizamos Kubernetes necesitamos tener en cuenta que nuestros servicios soporten las APIs correctamente, que estemos en versiones soportadas, que nuestra aplicación sea segura, etc.

En este post voy a explicar cómo he hecho para gestionar el mantenimiento de servicios y cómo estoy optimizando todo el proceso para que el tiempo que tengo que dedicarle sea cómodo para mi. Espero que os guste.

Un poco de contexto Fuera del trabajo, mantengo la infraestructura de un par de blogs y un par de servidores que tengo desplegados en casa. Siempre a modo de hobby, pero tratando de ser lo más profesional posible. Para ello, me autoimpuse una serie de normas que intento seguir a rajatabla:

Toda la infraestructura y sus despliegues deben hacerse con código y automatizarse si merece la pena.ado para que apunte a un workspace de Terraform Cloud.

  • Crear una variable de entorno con un token para acceder a dicho workspace.

En resumen:

terraform {
  cloud {
    organization = "tangelov"
    workspaces {
      name = "dummy"
    }
  }
 # Definimos una nueva variable de entorno en el despliegue que coge el valor
 # del token de un secreto en GCP
 env {
          name = "ATLANTIS_TFE_TOKEN"
          value_from {
            secret_key_ref {
              key = "latest"
              name = google_secret_manager_secret.atlantis_tfe_token.secret_id
            }
          }
        }

La integración nos permite utilizar el almacenamiento del estado de Terraform en la nube, pero si intentamos hacer ejecuciones remotas debido a nuestra configuración actual, fallarán a la hora de generar cualquier plan:

│ Error: Saving a generated plan is currently not supported
│ 
│ Terraform Cloud does not support saving the generated execution plan
│ locally at this time.

Conclusiones finales

En general he disfrutado mucho con este “mini” proyecto de investigación y he aprendido mucho tanto de Terraform Cloud (ya hablaremos del tema) como de Atlantis. Sin embargo, es fácil ver que ambas soluciones se solapan y que una está recibiendo mucho más cariño que la otra últimamente.

Aunque la gestión relacionada con la seguridad (secretos, grupos, almacenamiento del estado, etc.) es mucho mejor en Terraform Cloud, hoy sólo nos vamos a centrar en los puntos fuertes y los flacos de Atlantis.

Como ya hemos comentado anteriormente, Atlantis es una herramienta enfocada al uso de GitOps, pero no me ha permitido hacer todo lo que me hubiera gustado. He sido incapaz de aplicar automáticamente el código tras pasar los tests y hacer que éste se mergeara. Parece que su diseño obliga a pasar por una validación a través de comentarios en Gitlab o Github en cuanto añades tests (y no voy a aplicar automáticamente nada sin testing) y deja el siguiente paso en espera, obligándonos a poner atlantis apply -p dummy.

Debido a mi experiencia profesional, esto me preocupa porque en entornos grandes y complejos, los pipelines genéricos pueden generar muchos comentarios (ruido) y no tienen porqué adaptarse a sus necesidades. Por ejemplo, es habitual que hasta que no se mergee a main el código no sea aplicado en Producción y esto no lo podemos hacer con Atlantis.

Aparte de esto, me sigue pareciendo una herramienta muy versátil y configurable. Podemos añadir cualquier otra CLI (Infracost, Regula o cualquier otra herramienta) al contenedor y personalizar la ejecución al gusto. También podemos gestionar los pipelines de forma centralizada eligiendo qué pueden modificar los usuarios y que no, algo muy útil en determinadas ocasiones y creo que bien configurada puede ser segura y proporcionar workflows de infraestructura consistentes a pequeños equipos de desarrolladores.

En resumen, una herramienta interesante, consistente, que será muy útil a algunos y que se quedará corta a otros. En cualquier caso, espero que os haya gustado esta serie de posts y nos vemos en la siguiente. Un abrazo a todos.

Documentación

Revisado a 01-05-2023