Ansible Molecule para Testing de Roles

Molecule es el framework oficial de testing para roles de Ansible que permite verificar automáticamente que los roles funcionan correctamente antes de desplegarlos en producción. Con Molecule puedes definir escenarios de prueba, levantar instancias Docker o VMs de forma temporal, aplicar el rol, ejecutar assertions con Testinfra o Ansible y derribar el entorno de prueba, todo en un único comando, integrándolo fácilmente en pipelines de CI/CD.

Requisitos Previos

  • Python 3.8+
  • Docker instalado y en ejecución
  • Ansible instalado (o se instalará con Molecule)
  • El rol de Ansible a testear debe estar en la estructura estándar
# Verificar los requisitos previos
python3 --version
ansible --version
docker --version
docker info | grep "Server Version"

Instalación de Molecule

# Instalar Molecule con el driver de Docker y Testinfra
pip3 install molecule molecule-plugins[docker] pytest-testinfra ansible-lint

# Verificar la instalación
molecule --version
pytest --version

# Para entornos de producción, usar un virtualenv
python3 -m venv .venv
source .venv/bin/activate
pip install molecule molecule-plugins[docker] pytest-testinfra ansible-lint ansible

Crear un Escenario de Prueba

# Estructura de un rol Ansible con Molecule
# Si ya tienes un rol existente, agregar Molecule al mismo
cd mi-rol-ansible/

# Inicializar el escenario de prueba por defecto
molecule init scenario --driver-name docker

# La estructura creada:
# molecule/
# └── default/
#     ├── molecule.yml      # Configuración del escenario
#     ├── converge.yml      # Playbook que aplica el rol
#     ├── verify.yml        # Playbook de verificación (opcional)
#     └── tests/
#         └── test_default.py  # Tests de Testinfra

# O crear un rol nuevo con Molecule ya incluido
molecule init role --driver-name docker mi-nuevo-rol

Driver Docker

El driver Docker es el más usado por su rapidez y facilidad de configuración.

# Configuración completa del escenario Docker
cat > molecule/default/molecule.yml << 'EOF'
---
dependency:
  name: galaxy

driver:
  name: docker

platforms:
  # Ubuntu 22.04
  - name: ubuntu-22
    image: "geerlingguy/docker-ubuntu2204-ansible:latest"
    pre_build_image: true
    privileged: true
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    cgroupns_mode: host
    command: /lib/systemd/systemd
    
  # Ubuntu 20.04 (para compatibilidad con versiones anteriores)
  - name: ubuntu-20
    image: "geerlingguy/docker-ubuntu2004-ansible:latest"
    pre_build_image: true
    privileged: true
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    cgroupns_mode: host
    command: /lib/systemd/systemd
    
  # Rocky Linux 8
  - name: rockylinux-8
    image: "geerlingguy/docker-rockylinux8-ansible:latest"
    pre_build_image: true
    privileged: true
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    cgroupns_mode: host
    command: /usr/lib/systemd/systemd

provisioner:
  name: ansible
  playbooks:
    converge: converge.yml
    verify: verify.yml
  inventory:
    host_vars:
      ubuntu-22:
        ansible_user: root
      ubuntu-20:
        ansible_user: root
      rockylinux-8:
        ansible_user: root
  config_options:
    defaults:
      interpreter_python: auto_silent
      callback_result_format: yaml
  lint: |
    set -e
    ansible-lint

lint: |
  set -e
  yamllint .
  ansible-lint

verifier:
  name: ansible
EOF
# Playbook de convergencia: aplica el rol a testear
cat > molecule/default/converge.yml << 'EOF'
---
- name: Converge
  hosts: all
  become: true
  
  vars:
    # Variables de prueba para el rol
    mi_rol_puerto: 8080
    mi_rol_usuario: appuser
    mi_rol_habilitar_ssl: false
  
  pre_tasks:
    - name: Actualizar cache de apt (Ubuntu/Debian)
      apt:
        update_cache: true
        cache_valid_time: 3600
      when: ansible_os_family == "Debian"
    
    - name: Actualizar cache de yum (CentOS/Rocky)
      yum:
        update_cache: true
      when: ansible_os_family == "RedHat"
  
  roles:
    - role: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') | basename }}"
EOF

Fases de Molecule: Lint y Verify

# Playbook de verificación con Ansible (alternativa a Testinfra)
cat > molecule/default/verify.yml << 'EOF'
---
- name: Verify
  hosts: all
  become: true
  
  tasks:
    - name: Verificar que el paquete está instalado
      package_facts:
        manager: auto
    
    - name: Comprobar que nginx está instalado
      assert:
        that:
          - "'nginx' in ansible_facts.packages"
        fail_msg: "nginx no está instalado"
        success_msg: "nginx está instalado correctamente"
    
    - name: Verificar que el servicio nginx está corriendo
      service_facts:
    
    - name: Comprobar estado del servicio
      assert:
        that:
          - "ansible_facts.services['nginx.service'] is defined"
          - "ansible_facts.services['nginx.service']['state'] == 'running'"
          - "ansible_facts.services['nginx.service']['status'] == 'enabled'"
        fail_msg: "El servicio nginx no está en el estado esperado"
    
    - name: Verificar que el puerto está escuchando
      wait_for:
        port: 80
        host: localhost
        timeout: 10
    
    - name: Verificar la respuesta HTTP
      uri:
        url: http://localhost:80
        status_code: 200
      register: http_response
    
    - name: Comprobar que el directorio de configuración existe
      stat:
        path: /etc/nginx/sites-enabled
      register: nginx_dir
    
    - name: Verificar directorio de configuración
      assert:
        that:
          - nginx_dir.stat.exists
          - nginx_dir.stat.isdir
EOF

Assertions con Testinfra

# Configurar Testinfra para pruebas más avanzadas
cat > molecule/default/tests/test_default.py << 'PYTHON'
"""Tests de Testinfra para el rol de Nginx."""
import pytest
import testinfra

def test_nginx_installed(host):
    """Verificar que nginx está instalado."""
    pkg = host.package("nginx")
    assert pkg.is_installed

def test_nginx_service_running(host):
    """Verificar que el servicio nginx está corriendo y habilitado."""
    service = host.service("nginx")
    assert service.is_running
    assert service.is_enabled

def test_nginx_listening_on_port_80(host):
    """Verificar que nginx escucha en el puerto 80."""
    socket = host.socket("tcp://0.0.0.0:80")
    assert socket.is_listening

def test_nginx_config_directory(host):
    """Verificar que el directorio de configuración existe."""
    config_dir = host.file("/etc/nginx")
    assert config_dir.exists
    assert config_dir.is_directory
    assert config_dir.mode == 0o755

def test_nginx_default_config(host):
    """Verificar que el archivo de configuración principal existe."""
    config = host.file("/etc/nginx/nginx.conf")
    assert config.exists
    assert config.user == "root"
    assert config.group == "root"

def test_nginx_log_directory(host):
    """Verificar que el directorio de logs existe."""
    log_dir = host.file("/var/log/nginx")
    assert log_dir.exists
    assert log_dir.is_directory

def test_nginx_user_exists(host):
    """Verificar que el usuario nginx existe."""
    user = host.user("www-data")
    assert user.exists

def test_nginx_responds_to_http(host):
    """Verificar que nginx responde a las peticiones HTTP."""
    cmd = host.run("curl -s -o /dev/null -w '%{http_code}' http://localhost:80")
    assert cmd.succeeded
    assert cmd.stdout in ["200", "301", "302"]

def test_firewall_port_open(host):
    """Verificar que el puerto 80 está abierto en el firewall."""
    # Solo para sistemas con UFW
    if host.system_info.distribution == "ubuntu":
        cmd = host.run("ufw status | grep '80/tcp'")
        # Verificar que la regla existe o que UFW está inactivo
        assert cmd.rc in [0, 1]  # 0 si existe la regla, 1 si no hay regla (UFW inactivo)

# Tests parametrizados para múltiples configuraciones
@pytest.mark.parametrize("pkg", ["nginx", "openssl"])
def test_required_packages(host, pkg):
    """Verificar que todos los paquetes requeridos están instalados."""
    package = host.package(pkg)
    assert package.is_installed
PYTHON

# Configurar Molecule para usar Testinfra como verifier
cat >> molecule/default/molecule.yml << 'EOF'

verifier:
  name: testinfra
  options:
    v: true
    # Directorio donde están los tests
    pytest_addopts: "--tb=short"
EOF

Integración con CI/CD

# Pipeline de GitHub Actions para testing automático de roles Ansible
cat > .github/workflows/molecule.yml << 'EOF'
---
name: Molecule Tests

on:
  push:
    branches:
      - main
      - develop
  pull_request:
    branches:
      - main

jobs:
  molecule:
    name: Molecule - ${{ matrix.distro }}
    runs-on: ubuntu-latest
    
    strategy:
      fail-fast: false
      matrix:
        distro:
          - ubuntu-22
          - ubuntu-20
          - rockylinux-8
    
    steps:
      - name: Checkout del código
        uses: actions/checkout@v4
      
      - name: Configurar Python
        uses: actions/setup-python@v4
        with:
          python-version: "3.11"
      
      - name: Instalar dependencias
        run: |
          pip install molecule molecule-plugins[docker] pytest-testinfra ansible-lint ansible
      
      - name: Ejecutar lint de Ansible
        run: ansible-lint
      
      - name: Ejecutar tests de Molecule
        run: molecule test
        env:
          # Pasar la distribución como variable para seleccionar la plataforma
          MOLECULE_DISTRO: ${{ matrix.distro }}
          PY_COLORS: "1"
          ANSIBLE_FORCE_COLOR: "1"
      
      - name: Publicar resultados de tests
        uses: dorny/test-reporter@v1
        if: always()
        with:
          name: Molecule Results - ${{ matrix.distro }}
          path: "*.xml"
          reporter: java-junit
EOF

# Pipeline para GitLab CI
cat > .gitlab-ci.yml << 'EOF'
---
stages:
  - lint
  - test

variables:
  PY_COLORS: "1"
  ANSIBLE_FORCE_COLOR: "1"

lint:
  stage: lint
  image: python:3.11
  script:
    - pip install ansible-lint yamllint
    - yamllint .
    - ansible-lint

molecule-ubuntu22:
  stage: test
  image: docker:24
  services:
    - docker:dind
  variables:
    DOCKER_HOST: tcp://docker:2375
    DOCKER_TLS_CERTDIR: ""
  before_script:
    - apk add python3 py3-pip
    - pip install molecule molecule-plugins[docker] pytest-testinfra ansible
  script:
    - molecule test
  only:
    - main
    - merge_requests
EOF

Testing Multi-Plataforma

# Ejecutar los tests en todas las plataformas definidas
molecule test

# Ejecutar solo en una plataforma específica
molecule test --platform-name ubuntu-22

# Ejecutar fases individuales de Molecule para desarrollo
molecule create          # Crear las instancias Docker
molecule converge        # Aplicar el rol (sin destruir)
molecule verify          # Ejecutar solo la verificación
molecule lint            # Solo lint
molecule idempotence     # Verificar idempotencia del rol
molecule destroy         # Destruir las instancias

# Verificar la idempotencia: el rol no debe hacer cambios si se aplica dos veces
molecule idempotence

# Conectarse a una instancia para debugging
molecule login --host ubuntu-22

# Ver el estado de las instancias
molecule list

# Ejecutar un comando en todas las instancias
molecule exec -- systemctl status nginx

Solución de Problemas

Error al crear las instancias Docker:

# Verificar que Docker está corriendo
systemctl status docker
docker info

# Verificar que el usuario tiene permisos para Docker
groups $USER | grep docker
# Si no está en el grupo docker:
usermod -aG docker $USER && newgrp docker

# Intentar crear las instancias con debug
molecule create --debug

# Ver los logs de Docker si falla
docker events &
molecule create

El rol no es idempotente:

# Ejecutar la verificación de idempotencia para ver qué cambia
molecule idempotence

# El output mostrará las tareas que hacen cambios en el segundo run
# Revisar esas tareas y agregar condiciones when: para hacerlas idempotentes

# Ejemplo de tarea no idempotente y cómo corregirla:
# ANTES (no idempotente):
# - name: Agregar línea al archivo de configuración
#   lineinfile:
#     path: /etc/mi-app/config
#     line: "max_connections=100"
# 
# DESPUÉS (idempotente):
# - name: Agregar línea al archivo de configuración
#   lineinfile:
#     path: /etc/mi-app/config
#     line: "max_connections=100"
#     regexp: "^max_connections="   # Buscar si ya existe

Conclusión

Molecule transforma el desarrollo de roles Ansible de un proceso manual y propenso a errores en un flujo de trabajo automatizado y reproducible. La combinación de testing multi-plataforma, verificación de idempotencia y assertions detalladas con Testinfra garantiza que los roles funcionan correctamente en todos los sistemas objetivo antes de llegar a producción. Integrar Molecule en los pipelines de CI/CD convierte el testing de infraestructura en una parte natural del proceso de desarrollo, elevando la calidad y confiabilidad de la automatización de sistemas.